From 8313c759c641b68fc4b5ceaa56b422bd5ed612ae Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Tue, 23 Jun 2026 13:50:42 +0000 Subject: [PATCH] =?UTF-8?q?Migration=20modular=20monolith=20DDD=20(0.1=20?= =?UTF-8?q?=E2=86=92=203.3)=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/17 --- .claude/skills/ticket-executor/LEARNINGS.md | 108 + .gitea/workflows/pull-request.yml | 115 + config/modules.php | 28 + config/packages/api_platform.yaml | 14 + config/packages/doctrine.yaml | 73 +- config/packages/messenger.yaml | 2 +- config/packages/security.yaml | 4 +- config/services.yaml | 94 +- config/sidebar.php | 44 + .../plans/2026-06-19-lst-56-socle-back.md | 1155 ++++++ .../plans/2026-06-19-lst-57-rbac-fin.md | 1690 +++++++++ .../plans/2026-06-19-lst-61-audit-log.md | 706 ++++ .../plans/2026-06-19-lst-62-socle-front.md | 976 +++++ .../plans/2026-06-19-lst-63-module-core.md | 732 ++++ .../2026-06-20-lst-58-directory-prospect.md | 66 + ...6-06-20-lst-65-module-projectmanagement.md | 82 + ...2026-06-22-directory-commercial-reports.md | 3306 +++++++++++++++++ ...26-06-19-lst-56-modular-monolith-design.md | 192 + ...6-19-migration-modular-monolith-roadmap.md | 161 + ...-22-directory-commercial-reports-design.md | 203 + frontend/{ => app}/layouts/auth.vue | 0 frontend/{ => app}/layouts/default.vue | 177 +- frontend/{ => app}/middleware/admin.ts | 0 frontend/app/middleware/auth.global.ts | 30 + frontend/{ => app}/middleware/employee.ts | 0 frontend/app/middleware/modules.global.ts | 15 + .../admin/AdminAbsencePolicyTab.vue | 4 +- frontend/components/admin/AdminAuditTab.vue | 160 + .../components/admin/AdminBookStackTab.vue | 2 +- frontend/components/admin/AdminClientTab.vue | 4 +- frontend/components/admin/AdminEffortTab.vue | 4 +- frontend/components/admin/AdminGiteaTab.vue | 2 +- frontend/components/admin/AdminMailTab.vue | 2 +- .../components/admin/AdminPriorityTab.vue | 4 +- frontend/components/admin/AdminRoleTab.vue | 116 + frontend/components/admin/AdminShareTab.vue | 2 +- frontend/components/admin/AdminTagTab.vue | 4 +- .../components/admin/AdminWorkflowTab.vue | 4 +- frontend/components/admin/AdminZimbraTab.vue | 2 +- frontend/components/admin/RoleDrawer.vue | 186 + frontend/components/admin/WorkflowDrawer.vue | 10 +- .../components/share/SharedFilePreview.vue | 4 +- frontend/components/user/UserDrawer.vue | 53 +- frontend/i18n/locales/fr.json | 1769 +++++---- frontend/middleware/auth.global.ts | 16 - frontend/modules/.gitkeep | 0 .../AbsenceBalanceAdjustDrawer.vue | 4 +- .../components}/AbsenceBalanceCards.vue | 3 +- .../absence/components}/AbsenceCalendar.vue | 5 +- .../absence/components}/AbsenceDateField.vue | 2 +- .../components}/AbsenceDetailDrawer.vue | 5 +- .../components}/AbsenceRejectDrawer.vue | 5 +- .../components}/AbsenceRequestDrawer.vue | 5 +- .../absence/components}/EmployeeDrawer.vue | 0 .../absence}/composables/useAbsenceHelpers.ts | 2 +- frontend/modules/absence/nuxt.config.ts | 1 + .../{ => modules/absence}/pages/absences.vue | 5 +- .../absence}/pages/team-absences.vue | 5 +- .../absence}/services/absences.ts | 0 .../absence}/services/dto/absence.ts | 0 .../core/composables/usePermissions.ts | 27 + frontend/modules/core/nuxt.config.ts | 1 + frontend/{ => modules/core}/pages/login.vue | 0 frontend/{ => modules/core}/pages/profile.vue | 0 frontend/modules/core/services/audit-logs.ts | 65 + frontend/modules/core/services/permissions.ts | 22 + frontend/modules/core/services/roles.ts | 50 + .../directory/components}/ClientDrawer.vue | 31 +- .../components/CommercialReportTab.vue | 160 + .../components/DirectoryAddressBlock.vue | 69 + .../components/DirectoryContactBlock.vue | 73 + .../directory/components/ProspectDrawer.vue | 191 + .../components/ReportDocumentList.vue | 42 + .../components/ReportDocumentUpload.vue | 45 + .../composables/useDirectoryDetail.ts | 92 + frontend/modules/directory/nuxt.config.ts | 1 + .../pages/directory/clients/[id].vue | 107 + .../directory/pages/directory/index.vue | 241 ++ .../pages/directory/prospects/[id].vue | 107 + .../modules/directory/services/addresses.ts | 32 + .../directory}/services/clients.ts | 6 +- .../directory/services/commercial-reports.ts | 32 + .../modules/directory/services/contacts.ts | 32 + .../modules/directory/services/dto/address.ts | 23 + .../directory}/services/dto/client.ts | 6 - .../services/dto/commercial-report.ts | 27 + .../modules/directory/services/dto/contact.ts | 23 + .../directory/services/dto/prospect.ts | 28 + .../directory/services/dto/report-document.ts | 13 + .../modules/directory/services/prospects.ts | 44 + .../directory/services/report-documents.ts | 39 + .../composables/useShareStatus.ts | 2 +- frontend/modules/integration/nuxt.config.ts | 1 + .../integration}/services/bookstack.ts | 0 .../integration}/services/dto/bookstack.ts | 0 .../integration}/services/dto/gitea.ts | 0 .../integration}/services/dto/share.ts | 0 .../integration}/services/dto/zimbra.ts | 0 .../integration}/services/gitea.ts | 0 .../integration}/services/share-settings.ts | 0 .../integration}/services/share.ts | 0 .../integration}/services/zimbra.ts | 0 .../components}/MailAttachmentPreview.vue | 0 .../mail/components}/MailCreateTaskModal.vue | 16 +- .../mail/components}/MailFolderTree.vue | 2 +- .../mail/components}/MailLinkTaskModal.vue | 10 +- .../mail/components}/MailMessageList.vue | 2 +- .../mail/components}/MailMessageViewer.vue | 4 +- .../mail/components}/MailRefreshButton.vue | 2 +- frontend/modules/mail/nuxt.config.ts | 1 + frontend/{ => modules/mail}/pages/mail.vue | 4 +- .../{ => modules/mail}/services/dto/mail.ts | 0 frontend/{ => modules/mail}/services/mail.ts | 4 +- frontend/{ => modules/mail}/stores/mail.ts | 4 +- .../components}/ProjectDrawer.vue | 14 +- .../components}/ProjectGroupTab.vue | 8 +- .../ProjectWorkflowSwitchModal.vue | 12 +- .../components}/StatusPickerPopover.vue | 2 +- .../components}/TaskBookStackLinks.vue | 4 +- .../components}/TaskBulkActions.vue | 12 +- .../components}/TaskCard.vue | 2 +- .../components}/TaskDocumentList.vue | 4 +- .../components}/TaskDocumentPreview.vue | 4 +- .../components}/TaskDocumentShareLinker.vue | 6 +- .../components}/TaskDocumentUpload.vue | 2 +- .../components}/TaskEffortDrawer.vue | 4 +- .../components}/TaskGitSection.vue | 6 +- .../components}/TaskGroupDrawer.vue | 8 +- .../components}/TaskListItem.vue | 2 +- .../components}/TaskModal.vue | 32 +- .../components}/TaskPriorityDrawer.vue | 4 +- .../components}/TaskTagDrawer.vue | 4 +- .../modules/project-management/nuxt.config.ts | 1 + .../project-management}/pages/my-tasks.vue | 52 +- .../pages/projects/[id]/archives.vue | 34 +- .../pages/projects/[id]/groups.vue | 4 +- .../pages/projects/[id]/index.vue | 41 +- .../pages/projects/index.vue | 8 +- .../services/dto/project.ts | 2 +- .../services/dto/task-document.ts | 2 +- .../services/dto/task-effort.ts | 0 .../services/dto/task-group.ts | 0 .../services/dto/task-priority.ts | 0 .../services/dto/task-recurrence.ts | 0 .../services/dto/task-status.ts | 0 .../services/dto/task-tag.ts | 0 .../project-management}/services/dto/task.ts | 2 +- .../services/dto/workflow.ts | 0 .../project-management}/services/projects.ts | 0 .../services/task-documents.ts | 5 +- .../services/task-efforts.ts | 0 .../services/task-groups.ts | 0 .../services/task-priorities.ts | 0 .../services/task-recurrences.ts | 0 .../services/task-statuses.ts | 0 .../project-management}/services/task-tags.ts | 0 .../project-management}/services/tasks.ts | 0 .../project-management}/services/workflows.ts | 0 .../components/ReportingBarChart.vue | 52 + .../components/ReportingDoughnut.vue | 56 + .../reporting/composables/useCsvExport.ts | 44 + .../composables/useReportingFilters.ts | 41 + frontend/modules/reporting/nuxt.config.ts | 1 + .../modules/reporting/pages/reporting.vue | 388 ++ .../reporting/services/dto/reporting.ts | 34 + .../modules/reporting/services/reporting.ts | 51 + .../components}/TimeEntryBlock.vue | 2 +- .../components}/TimeEntryContextMenu.vue | 2 +- .../components}/TimeEntryDrawer.vue | 8 +- .../components}/TimeEntryList.vue | 2 +- .../components}/TimeTrackingCalendar.vue | 4 +- .../components}/TimeTrackingExportDrawer.vue | 6 +- frontend/modules/time-tracking/nuxt.config.ts | 1 + .../time-tracking}/pages/time-tracking.vue | 10 +- .../time-tracking}/services/dto/time-entry.ts | 8 +- .../time-tracking}/services/time-entries.ts | 0 .../time-tracking}/stores/timer.ts | 6 +- frontend/nuxt.config.ts | 57 +- frontend/pages/admin.vue | 16 +- frontend/pages/documents.vue | 4 +- frontend/pages/index.vue | 20 +- frontend/services/dto/notification.ts | 2 +- frontend/services/dto/user-data.ts | 1 + frontend/services/users.ts | 6 +- frontend/{ => shared}/composables/useApi.ts | 2 +- frontend/shared/composables/useModules.ts | 22 + frontend/shared/composables/useSidebar.ts | 31 + frontend/{ => shared}/stores/auth.ts | 0 frontend/{ => shared}/stores/ui.ts | 0 frontend/shared/types/sidebar.ts | 11 + infra/prod/deploy.sh | 3 + makefile | 7 +- migrations/Version20260619145109.php | 74 + migrations/Version20260619185448.php | 56 + migrations/Version20260620161036.php | 52 + migrations/Version20260620161500.php | 79 + migrations/Version20260620170000.php | 81 + migrations/Version20260620180000.php | 53 + migrations/Version20260620190000.php | 48 + migrations/Version20260620200000.php | 54 + migrations/Version20260620201000.php | 125 + migrations/Version20260621120000.php | 115 + migrations/Version20260621130000.php | 41 + migrations/Version20260622090000.php | 120 + migrations/Version20260622100916.php | 222 ++ src/Command/GenerateApiTokenCommand.php | 4 +- src/DataFixtures/AppFixtures.php | 144 +- src/Module/Absence/AbsenceModule.php | 43 + .../Service/AbsenceBalanceService.php | 30 +- .../Absence/Domain}/Entity/AbsenceBalance.php | 26 +- .../Absence/Domain}/Entity/AbsencePolicy.php | 15 +- .../Absence/Domain}/Entity/AbsenceRequest.php | 37 +- .../Absence/Domain}/Enum/AbsenceStatus.php | 2 +- .../Absence/Domain}/Enum/AbsenceType.php | 2 +- .../Absence/Domain}/Enum/HalfDay.php | 2 +- .../AbsenceBalanceRepositoryInterface.php | 24 + .../AbsencePolicyRepositoryInterface.php | 23 + .../AbsenceRequestRepositoryInterface.php | 45 + .../Domain}/Service/AbsenceDayCalculator.php | 4 +- .../Domain}/Service/PublicHolidayProvider.php | 2 +- .../State/AbsenceBalanceProvider.php | 16 +- .../State/AbsenceCancelProcessor.php | 19 +- .../State/AbsenceRequestProcessor.php | 24 +- .../State/AbsenceRequestProvider.php | 16 +- .../State/AbsenceReviewProcessor.php | 12 +- .../Command/AccrueLeaveCommand.php | 28 +- .../Controller}/AbsenceCalendarController.php | 6 +- ...AbsenceJustificationDownloadController.php | 9 +- .../AbsenceJustificationUploadController.php | 7 +- .../Controller}/AbsencePreviewController.php | 22 +- .../Controller}/PublicHolidayController.php | 4 +- .../DoctrineAbsenceBalanceRepository.php | 37 + .../DoctrineAbsencePolicyRepository.php} | 14 +- .../DoctrineAbsenceRequestRepository.php} | 22 +- .../Mcp/Tool}/CancelAbsenceRequestTool.php | 14 +- .../Mcp/Tool}/CreateAbsenceRequestTool.php | 30 +- .../Mcp/Tool}/DeleteAbsenceRequestTool.php | 8 +- .../Mcp/Tool}/GetAbsenceRequestTool.php | 10 +- .../Mcp/Tool}/ListAbsenceBalancesTool.php | 16 +- .../Mcp/Tool}/ListAbsencePoliciesTool.php | 8 +- .../Mcp/Tool}/ListAbsenceRequestsTool.php | 18 +- .../Mcp/Tool}/ReviewAbsenceRequestTool.php | 18 +- .../Mcp/Tool}/UpdateAbsenceBalanceTool.php | 10 +- .../Mcp/Tool}/UpdateAbsencePolicyTool.php | 10 +- .../Core/Application/DTO/AuditLogOutput.php | 30 + .../Core/Application/Rbac/RbacSeeder.php | 36 + src/Module/Core/CoreModule.php | 42 + .../Core/Domain}/Entity/Notification.php | 17 +- src/Module/Core/Domain/Entity/Permission.php | 115 + src/Module/Core/Domain/Entity/Role.php | 151 + src/{ => Module/Core/Domain}/Entity/User.php | 130 +- .../Core/Domain}/Enum/ContractType.php | 2 +- .../Exception/SystemRoleDeletionException.php | 15 + .../PermissionRepositoryInterface.php | 22 + .../Repository/RoleRepositoryInterface.php | 19 + .../Repository/UserRepositoryInterface.php | 32 + .../Core/Domain/Security/SystemRoles.php | 11 + .../ApiPlatform/Pagination/DbalPaginator.php | 74 + .../Resource/AuditLogEntityTypesResource.php | 34 + .../ApiPlatform/Resource/AuditLogResource.php | 54 + .../ApiPlatform}/State/MeProvider.php | 13 +- .../State/NotificationProvider.php | 8 +- .../State/Processor/RoleProcessor.php | 45 + .../State/Processor/UserRbacProcessor.php | 43 + .../Provider/AuditLogEntityTypesProvider.php | 35 + .../State/Provider/AuditLogProvider.php | 247 ++ .../State/UserPasswordHasherProcessor.php | 4 +- .../Infrastructure/Audit/AuditLogWriter.php | 94 + .../Audit/RequestIdProvider.php | 33 + .../Console/SeedRbacCommand.php | 30 + .../Console/SyncPermissionsCommand.php | 84 + .../Controller/MarkAllReadController.php | 10 +- .../NotificationUnreadCountController.php | 10 +- .../RegenerateApiTokenController.php | 4 +- .../Controller/UserAvatarController.php | 4 +- .../Infrastructure/Doctrine/AuditListener.php | 513 +++ .../DoctrineNotificationRepository.php} | 12 +- .../Doctrine/DoctrinePermissionRepository.php | 51 + .../Doctrine/DoctrineRoleRepository.php | 42 + .../Doctrine/DoctrineUserRepository.php} | 41 +- .../Infrastructure/Mcp/Tool}/GetUserTool.php | 8 +- .../Mcp/Tool}/ListUsersTool.php | 6 +- .../Mcp/Tool}/UpdateUserTool.php | 10 +- src/Module/Core/Infrastructure/Notifier.php | 33 + .../Security/PermissionVoter.php | 38 + src/Module/Directory/DirectoryModule.php | 43 + .../Directory/Domain/Entity/Address.php | 183 + .../Directory/Domain}/Entity/Client.php | 71 +- .../Domain/Entity/CommercialReport.php | 187 + .../Directory/Domain/Entity/Contact.php | 183 + .../Directory/Domain/Entity/Prospect.php | 191 + .../Domain/Entity/ReportDocument.php | 166 + .../Directory/Domain/Enum/ProspectStatus.php | 25 + .../Directory/Domain/Enum/ReportType.php | 23 + .../Repository/AddressRepositoryInterface.php | 12 + .../Repository/ClientRepositoryInterface.php | 20 + .../CommercialReportRepositoryInterface.php | 12 + .../Repository/ContactRepositoryInterface.php | 12 + .../ProspectRepositoryInterface.php | 20 + .../ReportDocumentRepositoryInterface.php | 12 + .../State/ConvertProspectProcessor.php | 90 + .../State/ReportDocumentProcessor.php | 165 + .../ReportDocumentDownloadController.php | 56 + .../Doctrine/DoctrineAddressRepository.php | 26 + .../Doctrine/DoctrineClientRepository.php | 26 + .../DoctrineCommercialReportRepository.php | 26 + .../Doctrine/DoctrineContactRepository.php | 26 + .../Doctrine/DoctrineProspectRepository.php | 26 + .../DoctrineReportDocumentRepository.php | 26 + .../CommercialReportAuthorListener.php | 28 + .../EventListener/ReportDocumentListener.php | 32 + .../Mcp/Tool/ConvertProspectTool.php | 87 + .../Mcp/Tool}/CreateClientTool.php | 12 +- .../Mcp/Tool/CreateProspectTool.php | 60 + .../Mcp/Tool}/DeleteClientTool.php | 8 +- .../Mcp/Tool/DeleteProspectTool.php | 42 + .../Mcp/Tool}/GetClientTool.php | 10 +- .../Mcp/Tool/GetProspectTool.php | 37 + .../Mcp/Tool}/ListClientsTool.php | 6 +- .../Mcp/Tool/ListProspectsTool.php | 44 + .../Mcp/Tool}/UpdateClientTool.php | 22 +- .../Mcp/Tool/UpdateProspectTool.php | 76 + .../Domain}/Entity/BookStackConfiguration.php | 15 +- .../Domain}/Entity/GiteaConfiguration.php | 15 +- .../Domain}/Entity/ShareConfiguration.php | 15 +- .../Domain}/Entity/TaskBookStackLink.php | 15 +- .../Domain}/Entity/ZimbraConfiguration.php | 15 +- .../Exception/BookStackApiException.php | 2 +- .../Domain}/Exception/GiteaApiException.php | 2 +- .../Exception/InvalidPathException.php | 2 +- .../Exception/ShareConnectionException.php | 2 +- .../Exception/ShareNotConfiguredException.php | 2 +- ...kStackConfigurationRepositoryInterface.php | 12 + .../GiteaConfigurationRepositoryInterface.php | 12 + .../ShareConfigurationRepositoryInterface.php | 12 + .../TaskBookStackLinkRepositoryInterface.php | 17 + ...ZimbraConfigurationRepositoryInterface.php | 12 + .../Integration/Domain/Service}/FileEntry.php | 2 +- .../Domain/Service}/FileSource.php | 2 +- .../Domain/Service}/SharePathResolver.php | 4 +- .../Domain/Service}/ShareTestResult.php | 2 +- .../ApiPlatform/Resource}/BookStackLink.php | 16 +- .../Resource}/BookStackSearchResult.php | 8 +- .../Resource}/BookStackSettings.php | 6 +- .../ApiPlatform/Resource}/BookStackShelf.php | 4 +- .../Resource}/BookStackTestConnection.php | 4 +- .../ApiPlatform/Resource}/GiteaBranch.php | 6 +- .../ApiPlatform/Resource}/GiteaBranchName.php | 4 +- .../Resource}/GiteaPullRequest.php | 4 +- .../ApiPlatform/Resource}/GiteaRepository.php | 4 +- .../ApiPlatform/Resource}/GiteaSettings.php | 6 +- .../Resource}/GiteaTestConnection.php | 4 +- .../ApiPlatform/Resource}/ShareSettings.php | 6 +- .../Resource}/ShareTestConnection.php | 4 +- .../ApiPlatform/Resource}/ZimbraSettings.php | 6 +- .../Resource}/ZimbraTestConnection.php | 4 +- .../State/BookStackLinkProcessor.php | 17 +- .../State/BookStackLinkProvider.php | 10 +- .../State/BookStackSearchResultProvider.php | 15 +- .../State/BookStackSettingsProcessor.php | 12 +- .../State/BookStackSettingsProvider.php | 8 +- .../State/BookStackShelfProvider.php | 8 +- .../State/BookStackTestConnectionProvider.php | 6 +- .../State/GiteaBranchNameProvider.php | 13 +- .../State/GiteaBranchProcessor.php | 15 +- .../State/GiteaBranchProvider.php | 15 +- .../State/GiteaPullRequestProvider.php | 15 +- .../State/GiteaRepositoryProvider.php | 8 +- .../State/GiteaSettingsProcessor.php | 12 +- .../State/GiteaSettingsProvider.php | 8 +- .../State/GiteaTestConnectionProvider.php | 6 +- .../State/ShareSettingsProcessor.php | 12 +- .../State/ShareSettingsProvider.php | 8 +- .../State/ShareTestConnectionProvider.php | 6 +- .../State/ZimbraSettingsProcessor.php | 12 +- .../State/ZimbraSettingsProvider.php | 8 +- .../State/ZimbraTestConnectionProvider.php | 6 +- .../Controller}/ShareBrowseController.php | 16 +- .../Controller}/ShareDownloadController.php | 14 +- .../Controller}/ShareSearchController.php | 12 +- .../Controller}/ShareStatusController.php | 8 +- ...ctrineBookStackConfigurationRepository.php | 26 + .../DoctrineGiteaConfigurationRepository.php} | 10 +- .../DoctrineShareConfigurationRepository.php} | 10 +- .../DoctrineTaskBookStackLinkRepository.php | 34 + ...DoctrineZimbraConfigurationRepository.php} | 10 +- .../Service/BookStackApiService.php | 35 +- .../Service/GiteaApiService.php | 15 +- .../Infrastructure/Service}/SmbFileSource.php | 18 +- src/Module/Integration/IntegrationModule.php | 41 + .../Application}/Dto/MailAttachmentDto.php | 2 +- .../Mail/Application}/Dto/MailFolderDto.php | 2 +- .../Application}/Dto/MailMessageDetailDto.php | 2 +- .../Application}/Dto/MailMessageHeaderDto.php | 2 +- .../Mail/Application}/Dto/MailSyncReport.php | 2 +- .../Message/MailSyncRequested.php | 2 +- .../MailSyncRequestedHandler.php | 10 +- .../Application}/Service/MailSyncService.php | 24 +- .../Mail/Domain}/Entity/MailConfiguration.php | 15 +- .../Mail/Domain}/Entity/MailFolder.php | 6 +- .../Mail/Domain}/Entity/MailMessage.php | 6 +- .../Mail/Domain}/Entity/TaskMailLink.php | 24 +- .../Exception/MailProviderException.php | 2 +- .../Provider}/MailProviderInterface.php | 10 +- .../MailConfigurationRepositoryInterface.php | 12 + .../MailFolderRepositoryInterface.php | 17 + .../MailMessageRepositoryInterface.php | 49 + .../TaskMailLinkRepositoryInterface.php | 24 + .../ApiPlatform/Resource}/MailSettings.php | 6 +- .../State}/MailSettingsProcessor.php | 12 +- .../State}/MailSettingsProvider.php | 8 +- .../Console}/MailRedecodeHeadersCommand.php | 8 +- .../Console}/MailSyncCommand.php | 12 +- .../MailAttachmentDownloadController.php | 14 +- .../Controller}/MailCreateTaskController.php | 26 +- .../Controller}/MailFoldersListController.php | 8 +- .../Controller}/MailLinkTaskController.php | 21 +- .../MailMessageDetailController.php | 14 +- .../Controller}/MailMessageFlagController.php | 14 +- .../Controller}/MailMessageReadController.php | 14 +- .../MailMessagesListController.php | 12 +- .../Controller}/MailSyncTriggerController.php | 6 +- .../MailTestConnectionController.php | 6 +- .../Controller}/MailUnlinkTaskController.php | 19 +- .../Controller}/TaskMailsListController.php | 15 +- .../DoctrineMailConfigurationRepository.php} | 10 +- .../DoctrineMailFolderRepository.php} | 10 +- .../DoctrineMailMessageRepository.php} | 17 +- .../DoctrineTaskMailLinkRepository.php} | 18 +- .../Infrastructure/Imap}/ImapMailProvider.php | 19 +- .../Imap}/MimeHeaderDecoder.php | 2 +- .../Security/MailAccessChecker.php | 8 +- src/Module/Mail/MailModule.php | 41 + .../Domain}/Entity/Project.php | 27 +- .../ProjectManagement/Domain}/Entity/Task.php | 38 +- .../Domain}/Entity/TaskDocument.php | 17 +- .../Domain}/Entity/TaskEffort.php | 6 +- .../Domain}/Entity/TaskGroup.php | 6 +- .../Domain}/Entity/TaskPriority.php | 6 +- .../Domain}/Entity/TaskRecurrence.php | 8 +- .../Domain}/Entity/TaskStatus.php | 8 +- .../Domain}/Entity/TaskTag.php | 9 +- .../Domain}/Entity/Workflow.php | 8 +- .../Domain}/Enum/RecurrenceType.php | 2 +- .../Domain}/Enum/StatusCategory.php | 2 +- .../Repository/ProjectRepositoryInterface.php | 20 + .../TaskEffortRepositoryInterface.php | 20 + .../TaskGroupRepositoryInterface.php | 20 + .../TaskPriorityRepositoryInterface.php | 20 + .../TaskRecurrenceRepositoryInterface.php | 12 + .../Repository/TaskRepositoryInterface.php | 22 + .../TaskStatusRepositoryInterface.php | 22 + .../Repository/TaskTagRepositoryInterface.php | 20 + .../WorkflowRepositoryInterface.php | 22 + .../Resource}/SwitchWorkflowOutput.php | 2 +- .../ApiPlatform}/State/RecurrenceHandler.php | 24 +- .../State/SwitchProjectWorkflowProcessor.php | 10 +- .../State/TaskCalendarProcessor.php | 8 +- .../State/TaskDocumentProcessor.php | 18 +- .../State/TaskDocumentProvider.php | 8 +- .../State/TaskNumberProcessor.php | 10 +- .../State/WorkflowDeleteProcessor.php | 4 +- .../TaskDocumentDownloadController.php | 10 +- .../Doctrine/DoctrineProjectRepository.php | 26 + .../Doctrine/DoctrineTaskEffortRepository.php | 26 + .../Doctrine/DoctrineTaskGroupRepository.php | 26 + .../DoctrineTaskPriorityRepository.php | 26 + .../DoctrineTaskRecurrenceRepository.php | 26 + .../Doctrine/DoctrineTaskRepository.php} | 14 +- .../DoctrineTaskStatusRepository.php} | 15 +- .../Doctrine/DoctrineTaskTagRepository.php | 26 + .../Doctrine/DoctrineWorkflowRepository.php} | 12 +- .../EventListener/TaskDocumentListener.php | 4 +- .../TaskNotificationListener.php | 43 +- .../UniqueDefaultWorkflowListener.php | 4 +- .../Mcp/Tool/Project/CreateProjectTool.php | 12 +- .../Mcp/Tool/Project/DeleteProjectTool.php | 8 +- .../Mcp/Tool/Project/GetProjectTool.php | 14 +- .../Mcp/Tool/Project/ListProjectsTool.php | 8 +- .../Mcp/Tool/Project/UpdateProjectTool.php | 16 +- .../Mcp/Tool/Task/AddTaskDocumentTool.php | 10 +- .../Tool/Task/CreateTaskRecurrenceTool.php | 14 +- .../Mcp/Tool/Task/CreateTaskTool.php | 52 +- .../Mcp/Tool/Task/DeleteTaskDocumentTool.php | 4 +- .../Tool/Task/DeleteTaskRecurrenceTool.php | 10 +- .../Mcp/Tool/Task/DeleteTaskTool.php | 10 +- .../Mcp/Tool/Task/GetTaskTool.php | 10 +- .../Mcp/Tool/Task/ListTasksTool.php | 8 +- .../Mcp/Tool/Task/UpdateTaskDocumentTool.php | 4 +- .../Tool/Task/UpdateTaskRecurrenceTool.php | 12 +- .../Mcp/Tool/Task/UpdateTaskTool.php | 46 +- .../Mcp/Tool/TaskMeta/CreateEffortTool.php | 4 +- .../Mcp/Tool/TaskMeta/CreateGroupTool.php | 12 +- .../Mcp/Tool/TaskMeta/CreatePriorityTool.php | 4 +- .../Mcp/Tool/TaskMeta/CreateStatusTool.php | 12 +- .../Mcp/Tool/TaskMeta/CreateTagTool.php | 4 +- .../Mcp/Tool/TaskMeta/DeleteEffortTool.php | 8 +- .../Mcp/Tool/TaskMeta/DeleteGroupTool.php | 8 +- .../Mcp/Tool/TaskMeta/DeletePriorityTool.php | 8 +- .../Mcp/Tool/TaskMeta/DeleteStatusTool.php | 8 +- .../Mcp/Tool/TaskMeta/DeleteTagTool.php | 8 +- .../Mcp/Tool/TaskMeta/ListEffortsTool.php | 6 +- .../Mcp/Tool/TaskMeta/ListGroupsTool.php | 8 +- .../Mcp/Tool/TaskMeta/ListPrioritiesTool.php | 6 +- .../Mcp/Tool/TaskMeta/ListStatusesTool.php | 8 +- .../Mcp/Tool/TaskMeta/ListTagsTool.php | 6 +- .../Mcp/Tool/TaskMeta/UpdateEffortTool.php | 8 +- .../Mcp/Tool/TaskMeta/UpdateGroupTool.php | 10 +- .../Mcp/Tool/TaskMeta/UpdatePriorityTool.php | 8 +- .../Mcp/Tool/TaskMeta/UpdateStatusTool.php | 10 +- .../Mcp/Tool/TaskMeta/UpdateTagTool.php | 8 +- .../Mcp/Tool/Workflow/ListWorkflowsTool.php | 6 +- .../Workflow/SwitchProjectWorkflowTool.php | 6 +- .../Infrastructure}/Service/CalDavService.php | 19 +- .../Service/RecurrenceCalculator.php | 8 +- .../ProjectManagementModule.php | 43 + .../Application/DTO/AbsencesByTypeOutput.php | 17 + .../Application/DTO/TasksByStatusOutput.php | 19 + .../Application/DTO/TimePerProjectOutput.php | 19 + .../Application/DTO/TimePerUserOutput.php | 19 + .../Resource/AbsencesByTypeResource.php | 33 + .../Resource/TasksByStatusResource.php | 32 + .../Resource/TimePerProjectResource.php | 34 + .../Resource/TimePerUserResource.php | 33 + .../State/AbsencesByTypeProvider.php | 82 + .../ApiPlatform/State/ReportFilterTrait.php | 89 + .../State/TasksByStatusProvider.php | 67 + .../State/TimePerProjectProvider.php | 81 + .../ApiPlatform/State/TimePerUserProvider.php | 79 + src/Module/Reporting/ReportingModule.php | 44 + .../TimeTracking/Domain}/Entity/TimeEntry.php | 53 +- .../TimeEntryRepositoryInterface.php | 35 + .../State/ActiveTimeEntryProvider.php | 8 +- .../Controller/TimeEntryExportController.php | 30 +- .../Doctrine/DoctrineTimeEntryRepository.php} | 24 +- .../Export}/TimeEntryExportService.php | 6 +- .../Mcp/Tool}/CreateTimeEntryTool.php | 32 +- .../Mcp/Tool}/DeleteTimeEntryTool.php | 8 +- .../Mcp/Tool}/ListTimeEntriesTool.php | 8 +- .../Mcp/Tool}/UpdateTimeEntryTool.php | 28 +- .../TimeTracking/TimeTrackingModule.php | 41 + src/Repository/AbsenceBalanceRepository.php | 31 - .../BookStackConfigurationRepository.php | 22 - src/Repository/ClientRepository.php | 17 - src/Repository/ProjectRepository.php | 17 - .../TaskBookStackLinkRepository.php | 23 - src/Repository/TaskEffortRepository.php | 17 - src/Repository/TaskGroupRepository.php | 17 - src/Repository/TaskPriorityRepository.php | 17 - src/Repository/TaskRecurrenceRepository.php | 17 - src/Repository/TaskTagRepository.php | 17 - src/Security/ApiTokenAuthenticator.php | 6 +- .../CurrentUserProviderInterface.php | 12 + src/Shared/Domain/Attribute/AuditIgnore.php | 17 + src/Shared/Domain/Attribute/Auditable.php | 17 + .../Domain/Contract/BlamableInterface.php | 16 + .../Domain/Contract/ClientInterface.php | 15 + .../Domain/Contract/LeaveProfileInterface.php | 24 + .../Domain/Contract/NotifierInterface.php | 15 + .../Domain/Contract/ProjectInterface.php | 17 + src/Shared/Domain/Contract/TaskInterface.php | 19 + .../Domain/Contract/TaskTagInterface.php | 17 + .../Contract/TimestampableInterface.php | 18 + src/Shared/Domain/Contract/UserInterface.php | 32 + src/Shared/Domain/Module/ModuleInterface.php | 23 + src/Shared/Domain/Module/ModuleRegistry.php | 57 + src/Shared/Domain/Sidebar/SidebarFilter.php | 94 + .../Trait/TimestampableBlamableTrait.php | 71 + .../ApiPlatform/Resource}/AppVersion.php | 4 +- .../ApiPlatform/Resource/ModulesResource.php | 28 + .../ApiPlatform/Resource/SidebarResource.php | 34 + .../ApiPlatform}/State/AppVersionProvider.php | 4 +- .../ApiPlatform/State/ModulesProvider.php | 30 + .../ApiPlatform/State/SidebarProvider.php | 51 + .../Database/ColumnCommentsCatalog.php | 24 + .../TimestampableBlamableSubscriber.php | 64 + .../Infrastructure/Mcp}/Serializer.php | 85 +- .../Security/SecurityCurrentUserProvider.php | 23 + .../Service/TokenEncryptor.php | 2 +- .../Mail/MailFoldersControllerTest.php | 2 +- .../Mail/MailMessagesControllerTest.php | 2 +- .../Mail/MailSettingsControllerTest.php | 2 +- .../Mail/MailSyncTriggerControllerTest.php | 2 +- .../MailTaskIntegrationControllerTest.php | 10 +- .../Functional/Controller/ShareBrowseTest.php | 2 +- .../Functional/Controller/ShareSearchTest.php | 2 +- .../Controller/ShareSettingsTest.php | 2 +- .../TaskNotificationListenerTest.php | 12 +- .../Mcp/AbsenceRequestLifecycleTest.php | 46 +- .../Module/Core/AuditListenerTest.php | 108 + .../Module/Core/AuditLogApiTest.php | 140 + tests/Functional/Module/Core/NotifierTest.php | 35 + tests/Functional/Module/Core/RoleApiTest.php | 79 + .../Module/Core/SeedRbacCommandTest.php | 35 + .../Core/SyncPermissionsCommandTest.php | 29 + .../Module/Core/UserRbacApiTest.php | 134 + .../Directory/ProspectConversionTest.php | 74 + .../TaskTimestampableTest.php | 102 + .../Module/Reporting/ReportingApiTest.php | 96 + .../TimeEntryTimestampableTest.php | 111 + .../Functional/Shared/ModulesEndpointTest.php | 34 + .../Functional/Shared/SidebarEndpointTest.php | 69 + .../ConvertProspectProcessorTest.php | 67 + .../Directory/Domain/Enum/ReportTypeTest.php | 23 + tests/Unit/Mail/ImapMailProviderTest.php | 14 +- tests/Unit/Mail/MailSyncReportTest.php | 2 +- tests/Unit/Mail/MimeHeaderDecoderTest.php | 2 +- .../Unit/Mcp/CoercingSchemaGeneratorTest.php | 4 +- tests/Unit/Module/Core/CoreModuleTest.php | 43 + .../Core/Domain/Entity/PermissionTest.php | 58 + .../Module/Core/Domain/Entity/RoleTest.php | 58 + .../Security/PermissionVoterTest.php | 53 + .../MailConfigurationRepositoryTest.php | 8 +- .../Unit/Service/AbsenceDayCalculatorTest.php | 6 +- tests/Unit/Service/MailSyncServiceTest.php | 40 +- .../Service/PublicHolidayProviderTest.php | 2 +- tests/Unit/Service/SharePathResolverTest.php | 4 +- .../Database/ColumnCommentsCatalogTest.php | 33 + .../TimestampableBlamableSubscriberTest.php | 132 + .../Module/ModuleRegistryPermissionsTest.php | 29 + .../Unit/Shared/Module/ModuleRegistryTest.php | 81 + .../Unit/Shared/Sidebar/SidebarFilterTest.php | 130 + 622 files changed, 24802 insertions(+), 2864 deletions(-) create mode 100644 .gitea/workflows/pull-request.yml create mode 100644 config/modules.php create mode 100644 config/sidebar.php create mode 100644 docs/superpowers/plans/2026-06-19-lst-56-socle-back.md create mode 100644 docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md create mode 100644 docs/superpowers/plans/2026-06-19-lst-61-audit-log.md create mode 100644 docs/superpowers/plans/2026-06-19-lst-62-socle-front.md create mode 100644 docs/superpowers/plans/2026-06-19-lst-63-module-core.md create mode 100644 docs/superpowers/plans/2026-06-20-lst-58-directory-prospect.md create mode 100644 docs/superpowers/plans/2026-06-20-lst-65-module-projectmanagement.md create mode 100644 docs/superpowers/plans/2026-06-22-directory-commercial-reports.md create mode 100644 docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md create mode 100644 docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md create mode 100644 docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md rename frontend/{ => app}/layouts/auth.vue (100%) rename frontend/{ => app}/layouts/default.vue (55%) rename frontend/{ => app}/middleware/admin.ts (100%) create mode 100644 frontend/app/middleware/auth.global.ts rename frontend/{ => app}/middleware/employee.ts (100%) create mode 100644 frontend/app/middleware/modules.global.ts create mode 100644 frontend/components/admin/AdminAuditTab.vue create mode 100644 frontend/components/admin/AdminRoleTab.vue create mode 100644 frontend/components/admin/RoleDrawer.vue delete mode 100644 frontend/middleware/auth.global.ts create mode 100644 frontend/modules/.gitkeep rename frontend/{components/absence => modules/absence/components}/AbsenceBalanceAdjustDrawer.vue (93%) rename frontend/{components/absence => modules/absence/components}/AbsenceBalanceCards.vue (97%) rename frontend/{components/absence => modules/absence/components}/AbsenceCalendar.vue (96%) rename frontend/{components/absence => modules/absence/components}/AbsenceDateField.vue (96%) rename frontend/{components/absence => modules/absence/components}/AbsenceDetailDrawer.vue (97%) rename frontend/{components/absence => modules/absence/components}/AbsenceRejectDrawer.vue (91%) rename frontend/{components/absence => modules/absence/components}/AbsenceRequestDrawer.vue (98%) rename frontend/{components/absence => modules/absence/components}/EmployeeDrawer.vue (100%) rename frontend/{ => modules/absence}/composables/useAbsenceHelpers.ts (98%) create mode 100644 frontend/modules/absence/nuxt.config.ts rename frontend/{ => modules/absence}/pages/absences.vue (97%) rename frontend/{ => modules/absence}/pages/team-absences.vue (99%) rename frontend/{ => modules/absence}/services/absences.ts (100%) rename frontend/{ => modules/absence}/services/dto/absence.ts (100%) create mode 100644 frontend/modules/core/composables/usePermissions.ts create mode 100644 frontend/modules/core/nuxt.config.ts rename frontend/{ => modules/core}/pages/login.vue (100%) rename frontend/{ => modules/core}/pages/profile.vue (100%) create mode 100644 frontend/modules/core/services/audit-logs.ts create mode 100644 frontend/modules/core/services/permissions.ts create mode 100644 frontend/modules/core/services/roles.ts rename frontend/{components/client => modules/directory/components}/ClientDrawer.vue (73%) create mode 100644 frontend/modules/directory/components/CommercialReportTab.vue create mode 100644 frontend/modules/directory/components/DirectoryAddressBlock.vue create mode 100644 frontend/modules/directory/components/DirectoryContactBlock.vue create mode 100644 frontend/modules/directory/components/ProspectDrawer.vue create mode 100644 frontend/modules/directory/components/ReportDocumentList.vue create mode 100644 frontend/modules/directory/components/ReportDocumentUpload.vue create mode 100644 frontend/modules/directory/composables/useDirectoryDetail.ts create mode 100644 frontend/modules/directory/nuxt.config.ts create mode 100644 frontend/modules/directory/pages/directory/clients/[id].vue create mode 100644 frontend/modules/directory/pages/directory/index.vue create mode 100644 frontend/modules/directory/pages/directory/prospects/[id].vue create mode 100644 frontend/modules/directory/services/addresses.ts rename frontend/{ => modules/directory}/services/clients.ts (85%) create mode 100644 frontend/modules/directory/services/commercial-reports.ts create mode 100644 frontend/modules/directory/services/contacts.ts create mode 100644 frontend/modules/directory/services/dto/address.ts rename frontend/{ => modules/directory}/services/dto/client.ts (58%) create mode 100644 frontend/modules/directory/services/dto/commercial-report.ts create mode 100644 frontend/modules/directory/services/dto/contact.ts create mode 100644 frontend/modules/directory/services/dto/prospect.ts create mode 100644 frontend/modules/directory/services/dto/report-document.ts create mode 100644 frontend/modules/directory/services/prospects.ts create mode 100644 frontend/modules/directory/services/report-documents.ts rename frontend/{ => modules/integration}/composables/useShareStatus.ts (88%) create mode 100644 frontend/modules/integration/nuxt.config.ts rename frontend/{ => modules/integration}/services/bookstack.ts (100%) rename frontend/{ => modules/integration}/services/dto/bookstack.ts (100%) rename frontend/{ => modules/integration}/services/dto/gitea.ts (100%) rename frontend/{ => modules/integration}/services/dto/share.ts (100%) rename frontend/{ => modules/integration}/services/dto/zimbra.ts (100%) rename frontend/{ => modules/integration}/services/gitea.ts (100%) rename frontend/{ => modules/integration}/services/share-settings.ts (100%) rename frontend/{ => modules/integration}/services/share.ts (100%) rename frontend/{ => modules/integration}/services/zimbra.ts (100%) rename frontend/{components/mail => modules/mail/components}/MailAttachmentPreview.vue (100%) rename frontend/{components/mail => modules/mail/components}/MailCreateTaskModal.vue (89%) rename frontend/{components/mail => modules/mail/components}/MailFolderTree.vue (98%) rename frontend/{components/mail => modules/mail/components}/MailLinkTaskModal.vue (96%) rename frontend/{components/mail => modules/mail/components}/MailMessageList.vue (98%) rename frontend/{components/mail => modules/mail/components}/MailMessageViewer.vue (98%) rename frontend/{components/mail => modules/mail/components}/MailRefreshButton.vue (89%) create mode 100644 frontend/modules/mail/nuxt.config.ts rename frontend/{ => modules/mail}/pages/mail.vue (97%) rename frontend/{ => modules/mail}/services/dto/mail.ts (100%) rename frontend/{ => modules/mail}/services/mail.ts (98%) rename frontend/{ => modules/mail}/stores/mail.ts (99%) rename frontend/{components/project => modules/project-management/components}/ProjectDrawer.vue (94%) rename frontend/{components/project => modules/project-management/components}/ProjectGroupTab.vue (94%) rename frontend/{components/project => modules/project-management/components}/ProjectWorkflowSwitchModal.vue (94%) rename frontend/{components/task => modules/project-management/components}/StatusPickerPopover.vue (91%) rename frontend/{components/task => modules/project-management/components}/TaskBookStackLinks.vue (96%) rename frontend/{components/task => modules/project-management/components}/TaskBulkActions.vue (92%) rename frontend/{components/task => modules/project-management/components}/TaskCard.vue (98%) rename frontend/{components/task => modules/project-management/components}/TaskDocumentList.vue (95%) rename frontend/{components/task => modules/project-management/components}/TaskDocumentPreview.vue (97%) rename frontend/{components/task => modules/project-management/components}/TaskDocumentShareLinker.vue (95%) rename frontend/{components/task => modules/project-management/components}/TaskDocumentUpload.vue (97%) rename frontend/{components/task => modules/project-management/components}/TaskEffortDrawer.vue (91%) rename frontend/{components/task => modules/project-management/components}/TaskGitSection.vue (98%) rename frontend/{components/task => modules/project-management/components}/TaskGroupDrawer.vue (93%) rename frontend/{components/task => modules/project-management/components}/TaskListItem.vue (98%) rename frontend/{components/task => modules/project-management/components}/TaskModal.vue (97%) rename frontend/{components/task => modules/project-management/components}/TaskPriorityDrawer.vue (92%) rename frontend/{components/task => modules/project-management/components}/TaskTagDrawer.vue (93%) create mode 100644 frontend/modules/project-management/nuxt.config.ts rename frontend/{ => modules/project-management}/pages/my-tasks.vue (90%) rename frontend/{ => modules/project-management}/pages/projects/[id]/archives.vue (76%) rename frontend/{ => modules/project-management}/pages/projects/[id]/groups.vue (82%) rename frontend/{ => modules/project-management}/pages/projects/[id]/index.vue (90%) rename frontend/{ => modules/project-management}/pages/projects/index.vue (94%) rename frontend/{ => modules/project-management}/services/dto/project.ts (92%) rename frontend/{ => modules/project-management}/services/dto/task-document.ts (81%) rename frontend/{ => modules/project-management}/services/dto/task-effort.ts (100%) rename frontend/{ => modules/project-management}/services/dto/task-group.ts (100%) rename frontend/{ => modules/project-management}/services/dto/task-priority.ts (100%) rename frontend/{ => modules/project-management}/services/dto/task-recurrence.ts (100%) rename frontend/{ => modules/project-management}/services/dto/task-status.ts (100%) rename frontend/{ => modules/project-management}/services/dto/task-tag.ts (100%) rename frontend/{ => modules/project-management}/services/dto/task.ts (96%) rename frontend/{ => modules/project-management}/services/dto/workflow.ts (100%) rename frontend/{ => modules/project-management}/services/projects.ts (100%) rename frontend/{ => modules/project-management}/services/task-documents.ts (94%) rename frontend/{ => modules/project-management}/services/task-efforts.ts (100%) rename frontend/{ => modules/project-management}/services/task-groups.ts (100%) rename frontend/{ => modules/project-management}/services/task-priorities.ts (100%) rename frontend/{ => modules/project-management}/services/task-recurrences.ts (100%) rename frontend/{ => modules/project-management}/services/task-statuses.ts (100%) rename frontend/{ => modules/project-management}/services/task-tags.ts (100%) rename frontend/{ => modules/project-management}/services/tasks.ts (100%) rename frontend/{ => modules/project-management}/services/workflows.ts (100%) create mode 100644 frontend/modules/reporting/components/ReportingBarChart.vue create mode 100644 frontend/modules/reporting/components/ReportingDoughnut.vue create mode 100644 frontend/modules/reporting/composables/useCsvExport.ts create mode 100644 frontend/modules/reporting/composables/useReportingFilters.ts create mode 100644 frontend/modules/reporting/nuxt.config.ts create mode 100644 frontend/modules/reporting/pages/reporting.vue create mode 100644 frontend/modules/reporting/services/dto/reporting.ts create mode 100644 frontend/modules/reporting/services/reporting.ts rename frontend/{components/time-tracking => modules/time-tracking/components}/TimeEntryBlock.vue (99%) rename frontend/{components/time-tracking => modules/time-tracking/components}/TimeEntryContextMenu.vue (96%) rename frontend/{components/time-tracking => modules/time-tracking/components}/TimeEntryDrawer.vue (96%) rename frontend/{components/time-tracking => modules/time-tracking/components}/TimeEntryList.vue (98%) rename frontend/{components/time-tracking => modules/time-tracking/components}/TimeTrackingCalendar.vue (99%) rename frontend/{components/time-tracking => modules/time-tracking/components}/TimeTrackingExportDrawer.vue (96%) create mode 100644 frontend/modules/time-tracking/nuxt.config.ts rename frontend/{ => modules/time-tracking}/pages/time-tracking.vue (97%) rename frontend/{ => modules/time-tracking}/services/dto/time-entry.ts (62%) rename frontend/{ => modules/time-tracking}/services/time-entries.ts (100%) rename frontend/{ => modules/time-tracking}/stores/timer.ts (93%) rename frontend/{ => shared}/composables/useApi.ts (99%) create mode 100644 frontend/shared/composables/useModules.ts create mode 100644 frontend/shared/composables/useSidebar.ts rename frontend/{ => shared}/stores/auth.ts (100%) rename frontend/{ => shared}/stores/ui.ts (100%) create mode 100644 frontend/shared/types/sidebar.ts create mode 100644 migrations/Version20260619145109.php create mode 100644 migrations/Version20260619185448.php create mode 100644 migrations/Version20260620161036.php create mode 100644 migrations/Version20260620161500.php create mode 100644 migrations/Version20260620170000.php create mode 100644 migrations/Version20260620180000.php create mode 100644 migrations/Version20260620190000.php create mode 100644 migrations/Version20260620200000.php create mode 100644 migrations/Version20260620201000.php create mode 100644 migrations/Version20260621120000.php create mode 100644 migrations/Version20260621130000.php create mode 100644 migrations/Version20260622090000.php create mode 100644 migrations/Version20260622100916.php create mode 100644 src/Module/Absence/AbsenceModule.php rename src/{ => Module/Absence/Application}/Service/AbsenceBalanceService.php (79%) rename src/{ => Module/Absence/Domain}/Entity/AbsenceBalance.php (85%) rename src/{ => Module/Absence/Domain}/Entity/AbsencePolicy.php (89%) rename src/{ => Module/Absence/Domain}/Entity/AbsenceRequest.php (87%) rename src/{ => Module/Absence/Domain}/Enum/AbsenceStatus.php (91%) rename src/{ => Module/Absence/Domain}/Enum/AbsenceType.php (96%) rename src/{ => Module/Absence/Domain}/Enum/HalfDay.php (87%) create mode 100644 src/Module/Absence/Domain/Repository/AbsenceBalanceRepositoryInterface.php create mode 100644 src/Module/Absence/Domain/Repository/AbsencePolicyRepositoryInterface.php create mode 100644 src/Module/Absence/Domain/Repository/AbsenceRequestRepositoryInterface.php rename src/{ => Module/Absence/Domain}/Service/AbsenceDayCalculator.php (96%) rename src/{ => Module/Absence/Domain}/Service/PublicHolidayProvider.php (98%) rename src/{ => Module/Absence/Infrastructure/ApiPlatform}/State/AbsenceBalanceProvider.php (77%) rename src/{ => Module/Absence/Infrastructure/ApiPlatform}/State/AbsenceCancelProcessor.php (87%) rename src/{ => Module/Absence/Infrastructure/ApiPlatform}/State/AbsenceRequestProcessor.php (81%) rename src/{ => Module/Absence/Infrastructure/ApiPlatform}/State/AbsenceRequestProvider.php (80%) rename src/{ => Module/Absence/Infrastructure/ApiPlatform}/State/AbsenceReviewProcessor.php (91%) rename src/{ => Module/Absence/Infrastructure}/Command/AccrueLeaveCommand.php (83%) rename src/{Controller/Absence => Module/Absence/Infrastructure/Controller}/AbsenceCalendarController.php (86%) rename src/{Controller/Absence => Module/Absence/Infrastructure/Controller}/AbsenceJustificationDownloadController.php (89%) rename src/{Controller/Absence => Module/Absence/Infrastructure/Controller}/AbsenceJustificationUploadController.php (92%) rename src/{Controller/Absence => Module/Absence/Infrastructure/Controller}/AbsencePreviewController.php (83%) rename src/{Controller/Absence => Module/Absence/Infrastructure/Controller}/PublicHolidayController.php (92%) create mode 100644 src/Module/Absence/Infrastructure/Doctrine/DoctrineAbsenceBalanceRepository.php rename src/{Repository/AbsencePolicyRepository.php => Module/Absence/Infrastructure/Doctrine/DoctrineAbsencePolicyRepository.php} (51%) rename src/{Repository/AbsenceRequestRepository.php => Module/Absence/Infrastructure/Doctrine/DoctrineAbsenceRequestRepository.php} (84%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/CancelAbsenceRequestTool.php (81%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/CreateAbsenceRequestTool.php (81%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/DeleteAbsenceRequestTool.php (81%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/GetAbsenceRequestTool.php (73%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/ListAbsenceBalancesTool.php (75%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/ListAbsencePoliciesTool.php (77%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/ListAbsenceRequestsTool.php (78%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/ReviewAbsenceRequestTool.php (85%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/UpdateAbsenceBalanceTool.php (82%) rename src/{Mcp/Tool/Absence => Module/Absence/Infrastructure/Mcp/Tool}/UpdateAbsencePolicyTool.php (86%) create mode 100644 src/Module/Core/Application/DTO/AuditLogOutput.php create mode 100644 src/Module/Core/Application/Rbac/RbacSeeder.php create mode 100644 src/Module/Core/CoreModule.php rename src/{ => Module/Core/Domain}/Entity/Notification.php (85%) create mode 100644 src/Module/Core/Domain/Entity/Permission.php create mode 100644 src/Module/Core/Domain/Entity/Role.php rename src/{ => Module/Core/Domain}/Entity/User.php (71%) rename src/{ => Module/Core/Domain}/Enum/ContractType.php (92%) create mode 100644 src/Module/Core/Domain/Exception/SystemRoleDeletionException.php create mode 100644 src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php create mode 100644 src/Module/Core/Domain/Repository/RoleRepositoryInterface.php create mode 100644 src/Module/Core/Domain/Repository/UserRepositoryInterface.php create mode 100644 src/Module/Core/Domain/Security/SystemRoles.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php rename src/{ => Module/Core/Infrastructure/ApiPlatform}/State/MeProvider.php (55%) rename src/{ => Module/Core/Infrastructure/ApiPlatform}/State/NotificationProvider.php (84%) create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php rename src/{ => Module/Core/Infrastructure/ApiPlatform}/State/UserPasswordHasherProcessor.php (92%) create mode 100644 src/Module/Core/Infrastructure/Audit/AuditLogWriter.php create mode 100644 src/Module/Core/Infrastructure/Audit/RequestIdProvider.php create mode 100644 src/Module/Core/Infrastructure/Console/SeedRbacCommand.php create mode 100644 src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php rename src/{ => Module/Core/Infrastructure}/Controller/MarkAllReadController.php (71%) rename src/{ => Module/Core/Infrastructure}/Controller/NotificationUnreadCountController.php (71%) rename src/{ => Module/Core/Infrastructure}/Controller/RegenerateApiTokenController.php (91%) rename src/{ => Module/Core/Infrastructure}/Controller/UserAvatarController.php (98%) create mode 100644 src/Module/Core/Infrastructure/Doctrine/AuditListener.php rename src/{Repository/NotificationRepository.php => Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php} (77%) create mode 100644 src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php create mode 100644 src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php rename src/{Repository/UserRepository.php => Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php} (59%) rename src/{Mcp/Tool/Reference => Module/Core/Infrastructure/Mcp/Tool}/GetUserTool.php (81%) rename src/{Mcp/Tool/Reference => Module/Core/Infrastructure/Mcp/Tool}/ListUsersTool.php (83%) rename src/{Mcp/Tool/Reference => Module/Core/Infrastructure/Mcp/Tool}/UpdateUserTool.php (90%) create mode 100644 src/Module/Core/Infrastructure/Notifier.php create mode 100644 src/Module/Core/Infrastructure/Security/PermissionVoter.php create mode 100644 src/Module/Directory/DirectoryModule.php create mode 100644 src/Module/Directory/Domain/Entity/Address.php rename src/{ => Module/Directory/Domain}/Entity/Client.php (64%) create mode 100644 src/Module/Directory/Domain/Entity/CommercialReport.php create mode 100644 src/Module/Directory/Domain/Entity/Contact.php create mode 100644 src/Module/Directory/Domain/Entity/Prospect.php create mode 100644 src/Module/Directory/Domain/Entity/ReportDocument.php create mode 100644 src/Module/Directory/Domain/Enum/ProspectStatus.php create mode 100644 src/Module/Directory/Domain/Enum/ReportType.php create mode 100644 src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php create mode 100644 src/Module/Directory/Domain/Repository/ClientRepositoryInterface.php create mode 100644 src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php create mode 100644 src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php create mode 100644 src/Module/Directory/Domain/Repository/ProspectRepositoryInterface.php create mode 100644 src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php create mode 100644 src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php create mode 100644 src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php create mode 100644 src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineAddressRepository.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineClientRepository.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineCommercialReportRepository.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineContactRepository.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineProspectRepository.php create mode 100644 src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php create mode 100644 src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php create mode 100644 src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php create mode 100644 src/Module/Directory/Infrastructure/Mcp/Tool/ConvertProspectTool.php rename src/{Mcp/Tool/Reference => Module/Directory/Infrastructure/Mcp/Tool}/CreateClientTool.php (78%) create mode 100644 src/Module/Directory/Infrastructure/Mcp/Tool/CreateProspectTool.php rename src/{Mcp/Tool/Reference => Module/Directory/Infrastructure/Mcp/Tool}/DeleteClientTool.php (81%) create mode 100644 src/Module/Directory/Infrastructure/Mcp/Tool/DeleteProspectTool.php rename src/{Mcp/Tool/Reference => Module/Directory/Infrastructure/Mcp/Tool}/GetClientTool.php (73%) create mode 100644 src/Module/Directory/Infrastructure/Mcp/Tool/GetProspectTool.php rename src/{Mcp/Tool/Reference => Module/Directory/Infrastructure/Mcp/Tool}/ListClientsTool.php (82%) create mode 100644 src/Module/Directory/Infrastructure/Mcp/Tool/ListProspectsTool.php rename src/{Mcp/Tool/Reference => Module/Directory/Infrastructure/Mcp/Tool}/UpdateClientTool.php (70%) create mode 100644 src/Module/Directory/Infrastructure/Mcp/Tool/UpdateProspectTool.php rename src/{ => Module/Integration/Domain}/Entity/BookStackConfiguration.php (72%) rename src/{ => Module/Integration/Domain}/Entity/GiteaConfiguration.php (65%) rename src/{ => Module/Integration/Domain}/Entity/ShareConfiguration.php (83%) rename src/{ => Module/Integration/Domain}/Entity/TaskBookStackLink.php (81%) rename src/{ => Module/Integration/Domain}/Entity/ZimbraConfiguration.php (78%) rename src/{ => Module/Integration/Domain}/Exception/BookStackApiException.php (70%) rename src/{ => Module/Integration/Domain}/Exception/GiteaApiException.php (69%) rename src/{Service/Share => Module/Integration/Domain}/Exception/InvalidPathException.php (69%) rename src/{Service/Share => Module/Integration/Domain}/Exception/ShareConnectionException.php (70%) rename src/{Service/Share => Module/Integration/Domain}/Exception/ShareNotConfiguredException.php (71%) create mode 100644 src/Module/Integration/Domain/Repository/BookStackConfigurationRepositoryInterface.php create mode 100644 src/Module/Integration/Domain/Repository/GiteaConfigurationRepositoryInterface.php create mode 100644 src/Module/Integration/Domain/Repository/ShareConfigurationRepositoryInterface.php create mode 100644 src/Module/Integration/Domain/Repository/TaskBookStackLinkRepositoryInterface.php create mode 100644 src/Module/Integration/Domain/Repository/ZimbraConfigurationRepositoryInterface.php rename src/{Service/Share => Module/Integration/Domain/Service}/FileEntry.php (85%) rename src/{Service/Share => Module/Integration/Domain/Service}/FileSource.php (92%) rename src/{Service/Share => Module/Integration/Domain/Service}/SharePathResolver.php (90%) rename src/{Service/Share => Module/Integration/Domain/Service}/ShareTestResult.php (79%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/BookStackLink.php (80%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/BookStackSearchResult.php (77%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/BookStackSettings.php (83%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/BookStackShelf.php (80%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/BookStackTestConnection.php (80%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/GiteaBranch.php (84%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/GiteaBranchName.php (78%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/GiteaPullRequest.php (87%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/GiteaRepository.php (81%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/GiteaSettings.php (82%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/GiteaTestConnection.php (80%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/ShareSettings.php (87%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/ShareTestConnection.php (81%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/ZimbraSettings.php (85%) rename src/{ApiResource => Module/Integration/Infrastructure/ApiPlatform/Resource}/ZimbraTestConnection.php (80%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackLinkProcessor.php (76%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackLinkProvider.php (81%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackSearchResultProvider.php (76%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackSettingsProcessor.php (75%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackSettingsProvider.php (66%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackShelfProvider.php (76%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/BookStackTestConnectionProvider.php (78%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaBranchNameProvider.php (72%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaBranchProcessor.php (75%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaBranchProvider.php (80%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaPullRequestProvider.php (78%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaRepositoryProvider.php (78%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaSettingsProcessor.php (73%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaSettingsProvider.php (67%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/GiteaTestConnectionProvider.php (78%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/ShareSettingsProcessor.php (79%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/ShareSettingsProvider.php (74%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/ShareTestConnectionProvider.php (80%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/ZimbraSettingsProcessor.php (78%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/ZimbraSettingsProvider.php (72%) rename src/{ => Module/Integration/Infrastructure/ApiPlatform}/State/ZimbraTestConnectionProvider.php (80%) rename src/{Controller/Share => Module/Integration/Infrastructure/Controller}/ShareBrowseController.php (82%) rename src/{Controller/Share => Module/Integration/Infrastructure/Controller}/ShareDownloadController.php (87%) rename src/{Controller/Share => Module/Integration/Infrastructure/Controller}/ShareSearchController.php (83%) rename src/{Controller/Share => Module/Integration/Infrastructure/Controller}/ShareStatusController.php (71%) create mode 100644 src/Module/Integration/Infrastructure/Doctrine/DoctrineBookStackConfigurationRepository.php rename src/{Repository/GiteaConfigurationRepository.php => Module/Integration/Infrastructure/Doctrine/DoctrineGiteaConfigurationRepository.php} (50%) rename src/{Repository/ShareConfigurationRepository.php => Module/Integration/Infrastructure/Doctrine/DoctrineShareConfigurationRepository.php} (56%) create mode 100644 src/Module/Integration/Infrastructure/Doctrine/DoctrineTaskBookStackLinkRepository.php rename src/{Repository/ZimbraConfigurationRepository.php => Module/Integration/Infrastructure/Doctrine/DoctrineZimbraConfigurationRepository.php} (56%) rename src/{ => Module/Integration/Infrastructure}/Service/BookStackApiService.php (87%) rename src/{ => Module/Integration/Infrastructure}/Service/GiteaApiService.php (94%) rename src/{Service/Share => Module/Integration/Infrastructure/Service}/SmbFileSource.php (89%) create mode 100644 src/Module/Integration/IntegrationModule.php rename src/{Mail => Module/Mail/Application}/Dto/MailAttachmentDto.php (85%) rename src/{Mail => Module/Mail/Application}/Dto/MailFolderDto.php (86%) rename src/{Mail => Module/Mail/Application}/Dto/MailMessageDetailDto.php (88%) rename src/{Mail => Module/Mail/Application}/Dto/MailMessageHeaderDto.php (92%) rename src/{Mail => Module/Mail/Application}/Dto/MailSyncReport.php (91%) rename src/{ => Module/Mail/Application}/Message/MailSyncRequested.php (77%) rename src/{ => Module/Mail/Application}/MessageHandler/MailSyncRequestedHandler.php (85%) rename src/{ => Module/Mail/Application}/Service/MailSyncService.php (94%) rename src/{ => Module/Mail/Domain}/Entity/MailConfiguration.php (87%) rename src/{ => Module/Mail/Domain}/Entity/MailFolder.php (92%) rename src/{ => Module/Mail/Domain}/Entity/MailMessage.php (96%) rename src/{ => Module/Mail/Domain}/Entity/TaskMailLink.php (70%) rename src/{Mail => Module/Mail/Domain}/Exception/MailProviderException.php (91%) rename src/{Mail => Module/Mail/Domain/Provider}/MailProviderInterface.php (88%) create mode 100644 src/Module/Mail/Domain/Repository/MailConfigurationRepositoryInterface.php create mode 100644 src/Module/Mail/Domain/Repository/MailFolderRepositoryInterface.php create mode 100644 src/Module/Mail/Domain/Repository/MailMessageRepositoryInterface.php create mode 100644 src/Module/Mail/Domain/Repository/TaskMailLinkRepositoryInterface.php rename src/{ApiResource => Module/Mail/Infrastructure/ApiPlatform/Resource}/MailSettings.php (90%) rename src/{State/Mail => Module/Mail/Infrastructure/ApiPlatform/State}/MailSettingsProcessor.php (87%) rename src/{State/Mail => Module/Mail/Infrastructure/ApiPlatform/State}/MailSettingsProvider.php (80%) rename src/{Command => Module/Mail/Infrastructure/Console}/MailRedecodeHeadersCommand.php (90%) rename src/{Command => Module/Mail/Infrastructure/Console}/MailSyncCommand.php (88%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailAttachmentDownloadController.php (87%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailCreateTaskController.php (83%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailFoldersListController.php (83%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailLinkTaskController.php (74%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailMessageDetailController.php (87%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailMessageFlagController.php (78%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailMessageReadController.php (78%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailMessagesListController.php (84%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailSyncTriggerController.php (87%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailTestConnectionController.php (88%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/MailUnlinkTaskController.php (69%) rename src/{Controller/Mail => Module/Mail/Infrastructure/Controller}/TaskMailsListController.php (79%) rename src/{Repository/MailConfigurationRepository.php => Module/Mail/Infrastructure/Doctrine/DoctrineMailConfigurationRepository.php} (58%) rename src/{Repository/MailFolderRepository.php => Module/Mail/Infrastructure/Doctrine/DoctrineMailFolderRepository.php} (66%) rename src/{Repository/MailMessageRepository.php => Module/Mail/Infrastructure/Doctrine/DoctrineMailMessageRepository.php} (90%) rename src/{Repository/TaskMailLinkRepository.php => Module/Mail/Infrastructure/Doctrine/DoctrineTaskMailLinkRepository.php} (59%) rename src/{Mail => Module/Mail/Infrastructure/Imap}/ImapMailProvider.php (95%) rename src/{Mail => Module/Mail/Infrastructure/Imap}/MimeHeaderDecoder.php (96%) rename src/{ => Module/Mail/Infrastructure}/Security/MailAccessChecker.php (80%) create mode 100644 src/Module/Mail/MailModule.php rename src/{ => Module/ProjectManagement/Domain}/Entity/Project.php (87%) rename src/{ => Module/ProjectManagement/Domain}/Entity/Task.php (91%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskDocument.php (88%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskEffort.php (87%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskGroup.php (93%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskPriority.php (89%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskRecurrence.php (94%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskStatus.php (93%) rename src/{ => Module/ProjectManagement/Domain}/Entity/TaskTag.php (85%) rename src/{ => Module/ProjectManagement/Domain}/Entity/Workflow.php (92%) rename src/{ => Module/ProjectManagement/Domain}/Enum/RecurrenceType.php (77%) rename src/{ => Module/ProjectManagement/Domain}/Enum/StatusCategory.php (81%) create mode 100644 src/Module/ProjectManagement/Domain/Repository/ProjectRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskEffortRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskGroupRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskPriorityRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskRecurrenceRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskStatusRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/TaskTagRepositoryInterface.php create mode 100644 src/Module/ProjectManagement/Domain/Repository/WorkflowRepositoryInterface.php rename src/{ApiResource => Module/ProjectManagement/Infrastructure/ApiPlatform/Resource}/SwitchWorkflowOutput.php (88%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/RecurrenceHandler.php (82%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/SwitchProjectWorkflowProcessor.php (92%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/TaskCalendarProcessor.php (91%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/TaskDocumentProcessor.php (94%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/TaskDocumentProvider.php (86%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/TaskNumberProcessor.php (82%) rename src/{ => Module/ProjectManagement/Infrastructure/ApiPlatform}/State/WorkflowDeleteProcessor.php (89%) rename src/{ => Module/ProjectManagement/Infrastructure}/Controller/TaskDocumentDownloadController.php (91%) create mode 100644 src/Module/ProjectManagement/Infrastructure/Doctrine/DoctrineProjectRepository.php create mode 100644 src/Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskEffortRepository.php create mode 100644 src/Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskGroupRepository.php create mode 100644 src/Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskPriorityRepository.php create mode 100644 src/Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskRecurrenceRepository.php rename src/{Repository/TaskRepository.php => Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskRepository.php} (72%) rename src/{Repository/TaskStatusRepository.php => Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskStatusRepository.php} (57%) create mode 100644 src/Module/ProjectManagement/Infrastructure/Doctrine/DoctrineTaskTagRepository.php rename src/{Repository/WorkflowRepository.php => Module/ProjectManagement/Infrastructure/Doctrine/DoctrineWorkflowRepository.php} (52%) rename src/{ => Module/ProjectManagement/Infrastructure}/EventListener/TaskDocumentListener.php (87%) rename src/{ => Module/ProjectManagement/Infrastructure}/EventListener/TaskNotificationListener.php (73%) rename src/{ => Module/ProjectManagement/Infrastructure}/EventListener/UniqueDefaultWorkflowListener.php (91%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Project/CreateProjectTool.php (80%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Project/DeleteProjectTool.php (80%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Project/GetProjectTool.php (77%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Project/ListProjectsTool.php (74%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Project/UpdateProjectTool.php (77%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/AddTaskDocumentTool.php (92%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/CreateTaskRecurrenceTool.php (86%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/CreateTaskTool.php (76%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/DeleteTaskDocumentTool.php (92%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/DeleteTaskRecurrenceTool.php (82%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/DeleteTaskTool.php (82%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/GetTaskTool.php (85%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/ListTasksTool.php (93%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/UpdateTaskDocumentTool.php (96%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/UpdateTaskRecurrenceTool.php (85%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Task/UpdateTaskTool.php (79%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/CreateEffortTool.php (87%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/CreateGroupTool.php (78%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/CreatePriorityTool.php (89%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/CreateStatusTool.php (84%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/CreateTagTool.php (89%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/DeleteEffortTool.php (79%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/DeleteGroupTool.php (79%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/DeletePriorityTool.php (79%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/DeleteStatusTool.php (80%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/DeleteTagTool.php (80%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/ListEffortsTool.php (78%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/ListGroupsTool.php (78%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/ListPrioritiesTool.php (79%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/ListStatusesTool.php (85%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/ListTagsTool.php (79%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/UpdateEffortTool.php (78%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/UpdateGroupTool.php (81%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/UpdatePriorityTool.php (81%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/UpdateStatusTool.php (86%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/TaskMeta/UpdateTagTool.php (82%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Workflow/ListWorkflowsTool.php (86%) rename src/{ => Module/ProjectManagement/Infrastructure}/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php (90%) rename src/{ => Module/ProjectManagement/Infrastructure}/Service/CalDavService.php (94%) rename src/{ => Module/ProjectManagement/Infrastructure}/Service/RecurrenceCalculator.php (96%) create mode 100644 src/Module/ProjectManagement/ProjectManagementModule.php create mode 100644 src/Module/Reporting/Application/DTO/AbsencesByTypeOutput.php create mode 100644 src/Module/Reporting/Application/DTO/TasksByStatusOutput.php create mode 100644 src/Module/Reporting/Application/DTO/TimePerProjectOutput.php create mode 100644 src/Module/Reporting/Application/DTO/TimePerUserOutput.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/AbsencesByTypeResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/TasksByStatusResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/TimePerProjectResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/Resource/TimePerUserResource.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/AbsencesByTypeProvider.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/ReportFilterTrait.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/TasksByStatusProvider.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerProjectProvider.php create mode 100644 src/Module/Reporting/Infrastructure/ApiPlatform/State/TimePerUserProvider.php create mode 100644 src/Module/Reporting/ReportingModule.php rename src/{ => Module/TimeTracking/Domain}/Entity/TimeEntry.php (76%) create mode 100644 src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php rename src/{ => Module/TimeTracking/Infrastructure/ApiPlatform}/State/ActiveTimeEntryProvider.php (72%) rename src/{ => Module/TimeTracking/Infrastructure}/Controller/TimeEntryExportController.php (80%) rename src/{Repository/TimeEntryRepository.php => Module/TimeTracking/Infrastructure/Doctrine/DoctrineTimeEntryRepository.php} (70%) rename src/{Service => Module/TimeTracking/Infrastructure/Export}/TimeEntryExportService.php (98%) rename src/{Mcp/Tool/TimeEntry => Module/TimeTracking/Infrastructure/Mcp/Tool}/CreateTimeEntryTool.php (74%) rename src/{Mcp/Tool/TimeEntry => Module/TimeTracking/Infrastructure/Mcp/Tool}/DeleteTimeEntryTool.php (80%) rename src/{Mcp/Tool/TimeEntry => Module/TimeTracking/Infrastructure/Mcp/Tool}/ListTimeEntriesTool.php (89%) rename src/{Mcp/Tool/TimeEntry => Module/TimeTracking/Infrastructure/Mcp/Tool}/UpdateTimeEntryTool.php (74%) create mode 100644 src/Module/TimeTracking/TimeTrackingModule.php delete mode 100644 src/Repository/AbsenceBalanceRepository.php delete mode 100644 src/Repository/BookStackConfigurationRepository.php delete mode 100644 src/Repository/ClientRepository.php delete mode 100644 src/Repository/ProjectRepository.php delete mode 100644 src/Repository/TaskBookStackLinkRepository.php delete mode 100644 src/Repository/TaskEffortRepository.php delete mode 100644 src/Repository/TaskGroupRepository.php delete mode 100644 src/Repository/TaskPriorityRepository.php delete mode 100644 src/Repository/TaskRecurrenceRepository.php delete mode 100644 src/Repository/TaskTagRepository.php create mode 100644 src/Shared/Application/CurrentUserProviderInterface.php create mode 100644 src/Shared/Domain/Attribute/AuditIgnore.php create mode 100644 src/Shared/Domain/Attribute/Auditable.php create mode 100644 src/Shared/Domain/Contract/BlamableInterface.php create mode 100644 src/Shared/Domain/Contract/ClientInterface.php create mode 100644 src/Shared/Domain/Contract/LeaveProfileInterface.php create mode 100644 src/Shared/Domain/Contract/NotifierInterface.php create mode 100644 src/Shared/Domain/Contract/ProjectInterface.php create mode 100644 src/Shared/Domain/Contract/TaskInterface.php create mode 100644 src/Shared/Domain/Contract/TaskTagInterface.php create mode 100644 src/Shared/Domain/Contract/TimestampableInterface.php create mode 100644 src/Shared/Domain/Contract/UserInterface.php create mode 100644 src/Shared/Domain/Module/ModuleInterface.php create mode 100644 src/Shared/Domain/Module/ModuleRegistry.php create mode 100644 src/Shared/Domain/Sidebar/SidebarFilter.php create mode 100644 src/Shared/Domain/Trait/TimestampableBlamableTrait.php rename src/{ApiResource => Shared/Infrastructure/ApiPlatform/Resource}/AppVersion.php (78%) create mode 100644 src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php rename src/{ => Shared/Infrastructure/ApiPlatform}/State/AppVersionProvider.php (83%) create mode 100644 src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php create mode 100644 src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php create mode 100644 src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php rename src/{Mcp/Tool => Shared/Infrastructure/Mcp}/Serializer.php (81%) create mode 100644 src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php rename src/{ => Shared/Infrastructure}/Service/TokenEncryptor.php (97%) create mode 100644 tests/Functional/Module/Core/AuditListenerTest.php create mode 100644 tests/Functional/Module/Core/AuditLogApiTest.php create mode 100644 tests/Functional/Module/Core/NotifierTest.php create mode 100644 tests/Functional/Module/Core/RoleApiTest.php create mode 100644 tests/Functional/Module/Core/SeedRbacCommandTest.php create mode 100644 tests/Functional/Module/Core/SyncPermissionsCommandTest.php create mode 100644 tests/Functional/Module/Core/UserRbacApiTest.php create mode 100644 tests/Functional/Module/Directory/ProspectConversionTest.php create mode 100644 tests/Functional/Module/ProjectManagement/TaskTimestampableTest.php create mode 100644 tests/Functional/Module/Reporting/ReportingApiTest.php create mode 100644 tests/Functional/Module/TimeTracking/TimeEntryTimestampableTest.php create mode 100644 tests/Functional/Shared/ModulesEndpointTest.php create mode 100644 tests/Functional/Shared/SidebarEndpointTest.php create mode 100644 tests/Module/Directory/ConvertProspectProcessorTest.php create mode 100644 tests/Module/Directory/Domain/Enum/ReportTypeTest.php create mode 100644 tests/Unit/Module/Core/CoreModuleTest.php create mode 100644 tests/Unit/Module/Core/Domain/Entity/PermissionTest.php create mode 100644 tests/Unit/Module/Core/Domain/Entity/RoleTest.php create mode 100644 tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php create mode 100644 tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php create mode 100644 tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php create mode 100644 tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php create mode 100644 tests/Unit/Shared/Module/ModuleRegistryTest.php create mode 100644 tests/Unit/Shared/Sidebar/SidebarFilterTest.php diff --git a/.claude/skills/ticket-executor/LEARNINGS.md b/.claude/skills/ticket-executor/LEARNINGS.md index a5f24ac..7e9fca6 100644 --- a/.claude/skills/ticket-executor/LEARNINGS.md +++ b/.claude/skills/ticket-executor/LEARNINGS.md @@ -54,8 +54,116 @@ - **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates - **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI +## Session 2026-06-19 (LST-56 / 0.1 — Socle back modular monolith) + +### Contexte +- Ticket exécuté via plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-56-socle-back.md`) délégué à un sous-agent (contexte isolé), pilotage MCP/chrono/vérif depuis la session principale. +- 4 tâches, 14 nouveaux tests (110 total, 216 assertions, vert), 4 commits (un par tâche). + +### Patterns +- **Strangler 100 % additif** : nouveau noyau `src/Shared/` (Domain/Contract, Domain/Module, Domain/Sidebar, Domain/Trait, Application, Infrastructure/{ApiPlatform,Doctrine,Security,Database}) sans toucher au métier — `make test` reste vert sans migration. +- **Endpoints DTO purs** : logique métier dans classes pures testées unitairement (`ModuleRegistry`, `SidebarFilter`), exposées par Providers API Platform minces (`ModulesProvider`/`SidebarProvider`) sur des Resources DTO. +- **resolve_target_entities** : contrat `Shared\Domain\Contract\UserInterface` mappé sur `App\Entity\User` (sera re-pointé vers `Module\Core\User` en 1.1). Inert tant qu'aucune entité n'utilise le trait. + +### Gotchas +- **API Platform 4 découvre les Resources sous `src/Shared/...` sans config `mapping.paths`** — le 404 anticipé dans le plan ne s'est pas produit, aucun ajout dans `api_platform.yaml` nécessaire. +- **Hook pre-commit php-cs-fixer** normalise le style du code fourni dans le plan : `\DateTimeImmutable`→`DateTimeImmutable` importé, FQN→`use`, `static::createClient()`→`self::`. Pur style, tests inchangés. Ne pas lutter contre. +- **`config/reference.php`** : fichier auto-généré qui apparaît modifié dans `git status` — ne jamais le committer. + +### Time tracking +- Le sous-agent a stoppé lui-même le timer d'implémentation (id 1005, 35 min) — garder le time-tracking sur la session principale pour rester maître du chrono si un sous-agent a accès aux tools MCP lesstime. + +## 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`, 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 `) 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 - **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 - **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 + +## 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\\...\` → `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.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. diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml new file mode 100644 index 0000000..f0cba23 --- /dev/null +++ b/.gitea/workflows/pull-request.yml @@ -0,0 +1,115 @@ +name: Pull Request — Quality gate + +# Lance les tests back + le build front sur chaque PR ciblant develop. +# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback. +# Pas d'E2E ici : la quality gate se limite a "le back passe les tests, le front compile". + +on: + pull_request: + branches: + - develop + +# Annule les runs obsoletes quand on repush sur la meme PR. +concurrency: + group: pr-${{ gitea.event.pull_request.number }} + cancel-in-progress: true + +jobs: + backend: + name: Backend (PHP CS + PHPUnit) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + # Doivent matcher la DATABASE_URL ci-dessous. Doctrine ajoute le + # suffixe `_test` automatiquement en APP_ENV=test (when@test + # dbname_suffix) → la base reellement utilisee est `app_test`. + POSTGRES_USER: app + POSTGRES_PASSWORD: '!ChangeMe!' + POSTGRES_DB: app + # Pas de `ports:` host mapping : les jobs Gitea Actions tournent en + # container sur un reseau Docker dedie, le service est joignable via + # son nom (`postgres`), pas via 127.0.0.1. + options: >- + --health-cmd "pg_isready -U app" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + APP_ENV: test + APP_SECRET: ci-secret-not-used + APP_DEBUG: 0 + DEFAULT_URI: http://localhost/ + DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8 + JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem' + JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem' + JWT_PASSPHRASE: ci-passphrase + # Cle de chiffrement (sodium) des secrets Mail / Integration / CalDav que + # les fixtures persistent (ZimbraConfiguration, tokens...). Valeur de test + # alignee sur phpunit.dist.xml. + ENCRYPTION_KEY: ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + # zip + gd requis par phpoffice/phpspreadsheet (export XLSX), sodium par + # le chiffrement des secrets, ctype/iconv par le require de composer.json. + extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd, ctype, iconv + coverage: none + tools: composer:v2 + + - name: Install PHP dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Generate JWT keypair + run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction + + - name: PHP CS Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff + + - name: Bootstrap test database + # Miroir de la cible `db-reset` du makefile (create + migrate + fixtures), + # en --env=test. Les fixtures sement les roles systeme (RbacSeeder) ; + # sync-permissions complete le catalogue de permissions comme en install reelle. + run: | + php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction + php bin/console doctrine:migrations:migrate --env=test --no-interaction + php bin/console doctrine:fixtures:load --env=test --no-interaction + php bin/console app:sync-permissions --env=test --no-interaction + + - name: Run PHPUnit + run: php -d memory_limit=512M vendor/bin/phpunit + + frontend: + name: Frontend (build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + + # `npm ci` declenche le postinstall `nuxt prepare` (genere .nuxt/). + - name: Install Node dependencies + run: npm ci + + # `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off + # (SPA), le prerender n'apporte rien a une quality gate — on valide seulement + # que le bundle compile. + - name: Build production (nuxt build) + run: npm run build diff --git a/config/modules.php b/config/modules.php new file mode 100644 index 0000000..b0b6e90 --- /dev/null +++ b/config/modules.php @@ -0,0 +1,28 @@ +.). + */ +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'], + // Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout. + ['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'], + ], + ], + [ + 'label' => 'sidebar.admin.section', + 'icon' => 'mdi:cog-outline', + 'roles' => ['ROLE_ADMIN'], + 'items' => [ + ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'], + ['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'], + ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'], + ['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'], + ], + ], +]; diff --git a/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md b/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md new file mode 100644 index 0000000..472349e --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-56-socle-back.md @@ -0,0 +1,1155 @@ +# LST-56 (0.1) — Socle back modular monolith — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Poser l'infrastructure backend d'un modular monolith DDD (endpoints `/api/modules` + `/api/sidebar`, registre de modules, garde-fous Timestampable/Blamable, helper de commentaires SQL) sans toucher au métier existant. + +**Architecture:** On ajoute un noyau `src/Shared/` (Domain/Contract, Domain/Trait, Infrastructure/ApiPlatform, Infrastructure/Doctrine, Infrastructure/Database). La logique métier (filtrage sidebar, extraction des IDs de modules, estampillage) est isolée dans des classes **pures** testées unitairement ; des Providers API Platform minces les exposent en HTTP. Aucune entité existante n'est déplacée. Strangler : 100 % additif. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16, PHPUnit 13. + +## Global Constraints + +- `declare(strict_types=1);` en tête de **tout** fichier PHP. +- Migrations **additives nullable uniquement** — aucun `DROP`, aucun `NOT NULL` rétroactif (prod Docker, BDD peuplée). +- **Zéro import inter-modules** : passer par `src/Shared/Domain/Contract/` ou domain events. +- Toute `GetCollection` reste **paginée** (pas concerné dans ce lot, aucune collection ajoutée). +- Toute colonne créée porte un `COMMENT ON COLUMN` (FR, ≤200 chars). +- PostgreSQL : noms de colonnes en **minuscules** dans le SQL brut. +- Commits : format `() : ` (espaces autour du `:`). **Jamais** de mention IA/Claude. +- Tests : exécution via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`. + +**Définitions différées (hors 0.1, ne PAS implémenter ici) :** mappings Doctrine de module + `migrations_paths` modulaire + `api_platform.mapping.paths` (arrivent avec le 1er module à entités, ticket 1.1). Filtrage sidebar **par permission** (ticket 1.2). `#[Auditable]` (ticket 1.3). + +--- + +### Task 1: Endpoint `GET /api/modules` + registre de modules + +**Files:** +- Create: `src/Shared/Domain/Module/ModuleInterface.php` +- Create: `src/Shared/Domain/Module/ModuleRegistry.php` +- Create: `src/Shared/Infrastructure/ApiPlatform/Resource/ModulesResource.php` +- Create: `src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php` +- Create: `config/modules.php` +- Modify: `config/packages/security.yaml` (access_control, rendre `/api/modules` public) +- Test: `tests/Unit/Shared/Module/ModuleRegistryTest.php` +- Test: `tests/Functional/Shared/ModulesEndpointTest.php` + +**Interfaces:** +- Produces: + - `interface ModuleInterface { public static function id(): string; public static function label(): string; public static function isRequired(): bool; /** @return list */ public static function permissions(): array; }` + - `ModuleRegistry::ids(array $moduleClasses): array` → `list` (les `id()` des classes implémentant `ModuleInterface`, ignore les autres). + - `config/modules.php` retourne `list>` (vide en 0.1). + +- [ ] **Step 1: Write the failing unit test for ModuleRegistry** + +Create `tests/Unit/Shared/Module/ModuleRegistryTest.php`: + +```php + + */ + public static function permissions(): array; +} +``` + +- [ ] **Step 4: Create `ModuleRegistry`** + +```php + $moduleClasses + * + * @return list + */ + public static function ids(array $moduleClasses): array + { + $ids = []; + foreach ($moduleClasses as $moduleClass) { + if (is_a($moduleClass, ModuleInterface::class, true)) { + $ids[] = $moduleClass::id(); + } + } + + return $ids; + } +} +``` + +- [ ] **Step 5: Run the unit test, verify it passes** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Module/ModuleRegistryTest.php` +Expected: PASS (3 tests). + +- [ ] **Step 6: Create `config/modules.php`** + +```php + ['modules:read']], + provider: ModulesProvider::class, + ), + ], +)] +final class ModulesResource +{ + /** + * @var list + */ + #[Groups(['modules:read'])] + public array $modules = []; +} +``` + +`src/Shared/Infrastructure/ApiPlatform/State/ModulesProvider.php`: + +```php + $classes */ + $classes = require $this->projectDir.'/config/modules.php'; + + $dto = new ModulesResource(); + $dto->modules = ModuleRegistry::ids($classes); + + return $dto; + } +} +``` + +- [ ] **Step 8: Make `/api/modules` public in `security.yaml`** + +In `config/packages/security.yaml`, under `access_control`, add the rule **immediately after** the `^/api/version` line (order matters — only the first matching rule applies): + +```yaml + # Liste des modules actifs en public (consommée au boot du front) + - { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] } +``` + +- [ ] **Step 9: Write the failing functional test** + +Create `tests/Functional/Shared/ModulesEndpointTest.php`: + +```php +request('GET', '/api/modules'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('modules', $data); + self::assertIsArray($data['modules']); + } +} +``` + +- [ ] **Step 10: Run the functional test, verify it passes** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Shared/ModulesEndpointTest.php` +Expected: PASS. (If FAIL with 404, confirm API Platform discovers `src/Shared/Infrastructure/ApiPlatform/Resource` — the default API Platform path config in API Platform 4 scans `src/ApiResource` + `src/Entity` only; if 404 persists, add `mapping.paths` for the Shared Resource dir in `config/packages/api_platform.yaml` and re-run. This is the one allowed config touch in Task 1.) + +- [ ] **Step 11: Commit** + +```bash +git add src/Shared/Domain/Module config/modules.php src/Shared/Infrastructure/ApiPlatform config/packages/security.yaml config/packages/api_platform.yaml tests/Unit/Shared/Module tests/Functional/Shared/ModulesEndpointTest.php +git commit -m "feat(modules) : expose GET /api/modules and module registry" +``` + +--- + +### Task 2: Endpoint `GET /api/sidebar` + filtre par module actif + +**Files:** +- Create: `src/Shared/Domain/Sidebar/SidebarFilter.php` +- Create: `src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php` +- Create: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` +- Create: `config/sidebar.php` +- Test: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +- Test: `tests/Functional/Shared/SidebarEndpointTest.php` + +**Interfaces:** +- Consumes: `ModuleRegistry::ids()` (Task 1), `config/modules.php` (Task 1). +- Produces: + - `SidebarFilter::filter(array $sections, array $activeModuleIds): array` → `array{sections: list}>, disabledRoutes: list}`. Règle : un item portant `module` absent de `$activeModuleIds` est masqué et son `to` ajouté à `disabledRoutes` ; une section vidée de tous ses items est supprimée ; les clés internes (`module`) sont retirées de la sortie. + - `config/sidebar.php` retourne `list}>`. + +- [ ] **Step 1: Write the failing unit test for SidebarFilter** + +Create `tests/Unit/Shared/Sidebar/SidebarFilterTest.php`: + +```php + 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [ + ['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'], + ]], + ]; + + $result = SidebarFilter::filter($sections, []); + + self::assertCount(1, $result['sections']); + self::assertSame('/', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]); + } + + public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void + { + $sections = [ + ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [ + ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'], + ]], + ]; + + $result = SidebarFilter::filter($sections, []); + + self::assertSame([], $result['sections']); + self::assertSame(['/time-tracking'], $result['disabledRoutes']); + } + + public function testItemWithActiveModuleIsVisible(): void + { + $sections = [ + ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [ + ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'], + ]], + ]; + + $result = SidebarFilter::filter($sections, ['time_tracking']); + + self::assertCount(1, $result['sections']); + self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + } +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: FAIL — `Class "App\Shared\Domain\Sidebar\SidebarFilter" not found`. + +- [ ] **Step 3: Create `SidebarFilter`** + +```php +}> $sections + * @param list $activeModuleIds + * + * @return array{sections: list}>, disabledRoutes: list} + */ + public static function filter(array $sections, array $activeModuleIds): array + { + $outSections = []; + $disabledRoutes = []; + + foreach ($sections as $section) { + $items = []; + foreach ($section['items'] as $item) { + $module = $item['module'] ?? null; + if (null !== $module && !in_array($module, $activeModuleIds, true)) { + $disabledRoutes[] = $item['to']; + + continue; + } + + $items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']]; + } + + if ([] !== $items) { + $outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items]; + } + } + + return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes]; + } +} +``` + +- [ ] **Step 4: Run the unit test, verify it passes** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: PASS (3 tests). + +- [ ] **Step 5: Create `config/sidebar.php`** + +Toutes les entrées actuelles sont **sans clé `module`** (donc visibles) ; les futurs modules ajouteront leur `module`. Labels = clés i18n. + +```php +.). + */ +return [ + [ + 'label' => 'sidebar.general.section', + 'icon' => 'mdi:view-dashboard-outline', + 'items' => [ + ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], + ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:checkbox-marked-circle-outline'], + ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-multiple-outline'], + ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:clock-outline'], + ], + ], + [ + 'label' => 'sidebar.hr.section', + 'icon' => 'mdi:calendar-account-outline', + 'items' => [ + ['label' => 'sidebar.hr.absences', 'to' => '/absences', 'icon' => 'mdi:calendar-remove-outline'], + ['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group-outline'], + ], + ], +]; +``` + +- [ ] **Step 6: Create `SidebarResource` and `SidebarProvider`** + +`src/Shared/Infrastructure/ApiPlatform/Resource/SidebarResource.php`: + +```php + ['sidebar:read']], + provider: SidebarProvider::class, + ), + ], +)] +final class SidebarResource +{ + /** + * @var list}> + */ + #[Groups(['sidebar:read'])] + public array $sections = []; + + /** + * @var list + */ + #[Groups(['sidebar:read'])] + public array $disabledRoutes = []; +} +``` + +`src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php`: + +```php + $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + /** @var list}> $sidebar */ + $sidebar = require $this->projectDir.'/config/sidebar.php'; + + $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses)); + + $dto = new SidebarResource(); + $dto->sections = $filtered['sections']; + $dto->disabledRoutes = $filtered['disabledRoutes']; + + return $dto; + } +} +``` + +- [ ] **Step 7: Write the failing functional test** + +Create `tests/Functional/Shared/SidebarEndpointTest.php`: + +```php +request('GET', '/api/sidebar'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSidebarReturnsSectionsForAuthenticatedUser(): void + { + $client = static::createClient(); + $container = static::getContainer(); + $em = $container->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('sections', $data); + self::assertArrayHasKey('disabledRoutes', $data); + self::assertNotEmpty($data['sections']); + } +} +``` + +- [ ] **Step 8: Run the functional test, verify it passes** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Shared/SidebarEndpointTest.php` +Expected: PASS (2 tests). + +- [ ] **Step 9: Commit** + +```bash +git add src/Shared/Domain/Sidebar src/Shared/Infrastructure/ApiPlatform config/sidebar.php tests/Unit/Shared/Sidebar tests/Functional/Shared/SidebarEndpointTest.php +git commit -m "feat(sidebar) : expose GET /api/sidebar filtered by active modules" +``` + +--- + +### Task 3: Garde-fou Timestampable / Blamable (trait + subscriber) + +**Files:** +- Create: `src/Shared/Domain/Contract/UserInterface.php` +- Create: `src/Shared/Domain/Contract/TimestampableInterface.php` +- Create: `src/Shared/Domain/Contract/BlamableInterface.php` +- Create: `src/Shared/Application/CurrentUserProviderInterface.php` +- Create: `src/Shared/Infrastructure/Security/SecurityCurrentUserProvider.php` +- Create: `src/Shared/Domain/Trait/TimestampableBlamableTrait.php` +- Create: `src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php` +- Modify: `src/Entity/User.php` (implement `UserInterface`) +- Modify: `config/packages/doctrine.yaml` (`resolve_target_entities`) +- Test: `tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` + +**Interfaces:** +- Produces: + - `interface UserInterface { public function getId(): ?int; }` + - `interface TimestampableInterface { public function getCreatedAt(): ?\DateTimeImmutable; public function setCreatedAt(\DateTimeImmutable $createdAt): void; public function getUpdatedAt(): ?\DateTimeImmutable; public function setUpdatedAt(\DateTimeImmutable $updatedAt): void; }` + - `interface BlamableInterface { public function getCreatedBy(): ?UserInterface; public function setCreatedBy(?UserInterface $user): void; public function getUpdatedBy(): ?UserInterface; public function setUpdatedBy(?UserInterface $user): void; }` + - `interface CurrentUserProviderInterface { public function getCurrentUser(): ?UserInterface; }` + - `TimestampableBlamableSubscriber::applyOnCreate(object $entity): void` and `::applyOnUpdate(object $entity): void` — pure-ish entry points used by the unit test; the Doctrine hooks delegate to them. + +> **Note (strangler):** en 0.1 le trait/subscriber n'est encore appliqué à **aucune** entité (les entités restent legacy). Le contrat `UserInterface` est mappé sur `App\Entity\User` via `resolve_target_entities` ; il sera re-pointé vers `App\Module\Core\Domain\Entity\User` au ticket 1.1. + +- [ ] **Step 1: Write the failing unit test for the subscriber** + +Create `tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php`: + +```php +makeUser(7); + $subscriber = new TimestampableBlamableSubscriber($this->providerReturning($user)); + $entity = $this->makeEntity(); + + $subscriber->applyOnCreate($entity); + + self::assertInstanceOf(\DateTimeImmutable::class, $entity->getCreatedAt()); + self::assertInstanceOf(\DateTimeImmutable::class, $entity->getUpdatedAt()); + self::assertSame($user, $entity->getCreatedBy()); + self::assertSame($user, $entity->getUpdatedBy()); + } + + public function testApplyOnUpdateLeavesCreatedUntouched(): void + { + $creator = $this->makeUser(1); + $editor = $this->makeUser(2); + $entity = $this->makeEntity(); + + (new TimestampableBlamableSubscriber($this->providerReturning($creator)))->applyOnCreate($entity); + $createdAt = $entity->getCreatedAt(); + + (new TimestampableBlamableSubscriber($this->providerReturning($editor)))->applyOnUpdate($entity); + + self::assertSame($createdAt, $entity->getCreatedAt()); + self::assertSame($creator, $entity->getCreatedBy()); + self::assertSame($editor, $entity->getUpdatedBy()); + } + + public function testApplyOnCreateIgnoresNonTimestampableEntities(): void + { + $subscriber = new TimestampableBlamableSubscriber($this->providerReturning(null)); + + // Must not throw. + $subscriber->applyOnCreate(new \stdClass()); + $this->addToAssertionCount(1); + } + + private function providerReturning(?UserInterface $user): CurrentUserProviderInterface + { + return new class($user) implements CurrentUserProviderInterface { + public function __construct(private ?UserInterface $user) {} + + public function getCurrentUser(): ?UserInterface + { + return $this->user; + } + }; + } + + private function makeUser(int $id): UserInterface + { + return new class($id) implements UserInterface { + public function __construct(private int $id) {} + + public function getId(): ?int + { + return $this->id; + } + }; + } + + private function makeEntity(): object + { + return new class implements TimestampableInterface, BlamableInterface { + use TimestampableBlamableTrait; + }; + } +} +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` +Expected: FAIL — interfaces/classes not found. + +- [ ] **Step 3: Create the contracts** + +`src/Shared/Domain/Contract/UserInterface.php`: + +```php +security->getUser(); + + return $user instanceof UserInterface ? $user : null; + } +} +``` + +- [ ] **Step 5: Create the trait** + +`src/Shared/Domain/Trait/TimestampableBlamableTrait.php`: + +```php +createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): void + { + $this->updatedAt = $updatedAt; + } + + public function getCreatedBy(): ?UserInterface + { + return $this->createdBy; + } + + public function setCreatedBy(?UserInterface $user): void + { + $this->createdBy = $user; + } + + public function getUpdatedBy(): ?UserInterface + { + return $this->updatedBy; + } + + public function setUpdatedBy(?UserInterface $user): void + { + $this->updatedBy = $user; + } +} +``` + +- [ ] **Step 6: Create the Doctrine subscriber** + +`src/Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber.php`: + +```php +applyOnCreate($args->getObject()); + } + + public function preUpdate(PreUpdateEventArgs $args): void + { + $this->applyOnUpdate($args->getObject()); + } + + public function applyOnCreate(object $entity): void + { + $now = new \DateTimeImmutable(); + + if ($entity instanceof TimestampableInterface) { + if (null === $entity->getCreatedAt()) { + $entity->setCreatedAt($now); + } + $entity->setUpdatedAt($now); + } + + if ($entity instanceof BlamableInterface) { + $user = $this->currentUserProvider->getCurrentUser(); + if (null === $entity->getCreatedBy()) { + $entity->setCreatedBy($user); + } + $entity->setUpdatedBy($user); + } + } + + public function applyOnUpdate(object $entity): void + { + if ($entity instanceof TimestampableInterface) { + $entity->setUpdatedAt(new \DateTimeImmutable()); + } + + if ($entity instanceof BlamableInterface) { + $entity->setUpdatedBy($this->currentUserProvider->getCurrentUser()); + } + } +} +``` + +- [ ] **Step 7: Make legacy `User` implement the contract + add `resolve_target_entities`** + +In `src/Entity/User.php`, add the interface to the class declaration (the entity already has `getId(): ?int`, so no method to add): + +```php +use App\Shared\Domain\Contract\UserInterface as SharedUserInterface; +// ... +class User implements /* existing interfaces, */ SharedUserInterface +``` + +> Keep all existing `implements` clauses; append `SharedUserInterface`. Alias avoids any clash with `Symfony\...\UserInterface` already imported. + +In `config/packages/doctrine.yaml`, under `orm:`, add: + +```yaml + resolve_target_entities: + App\Shared\Domain\Contract\UserInterface: App\Entity\User +``` + +- [ ] **Step 8: Run the unit test, verify it passes** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` +Expected: PASS (3 tests). + +- [ ] **Step 9: Run the full suite to confirm no regression** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` +Expected: PASS (no entity uses the trait yet; `resolve_target_entities` is inert until consumed). Confirm the prior 96 tests still pass. + +- [ ] **Step 10: Commit** + +```bash +git add src/Shared/Domain/Contract src/Shared/Application src/Shared/Infrastructure/Security src/Shared/Domain/Trait src/Shared/Infrastructure/Doctrine src/Entity/User.php config/packages/doctrine.yaml tests/Unit/Shared/Doctrine +git commit -m "feat(shared) : add timestampable/blamable trait and doctrine subscriber" +``` + +--- + +### Task 4: Helper `ColumnCommentsCatalog` (COMMENT ON COLUMN) + +**Files:** +- Create: `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php` +- Test: `tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php` + +**Interfaces:** +- Produces: `ColumnCommentsCatalog::timestampableBlamableComments(string $table): list` → la liste des instructions `COMMENT ON COLUMN .IS '...'` pour les 4 colonnes standard. Utilisé dans les migrations des modules (à partir de 1.1) via `$this->addSql(...)`. + +- [ ] **Step 1: Write the failing unit test** + +Create `tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php`: + +```php +addSql($statement); }. + * + * @return list + */ + public static function timestampableBlamableComments(string $table): array + { + return [ + "COMMENT ON COLUMN {$table}.created_at IS 'Date de creation (UTC). Rempli automatiquement (Timestampable).'", + "COMMENT ON COLUMN {$table}.updated_at IS 'Date de derniere modification (UTC). Rempli automatiquement (Timestampable).'", + "COMMENT ON COLUMN {$table}.created_by IS 'Auteur de la creation (FK user, SET NULL). Rempli automatiquement (Blamable).'", + "COMMENT ON COLUMN {$table}.updated_by IS 'Auteur de la derniere modification (FK user, SET NULL). Rempli automatiquement (Blamable).'", + ]; + } +} +``` + +- [ ] **Step 4: Run the unit test, verify it passes** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Database/ColumnCommentsCatalogTest.php` +Expected: PASS (2 tests). + +- [ ] **Step 5: Run the full suite + cs-fixer** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` +Expected: PASS (all green, including the 96 pre-existing tests). +Run: `make php-cs-fixer-allow-risky` +Expected: no remaining violations in `src/Shared` / `tests`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Shared/Infrastructure/Database tests/Unit/Shared/Database +git commit -m "feat(shared) : add column comments catalog helper for migrations" +``` + +--- + +## Acceptance check (run after all tasks) + +- [ ] `GET /api/modules` returns `{ "modules": [] }` (public, 200). +- [ ] `GET /api/sidebar` returns `{ sections, disabledRoutes }` (401 unauth, 200 auth). +- [ ] `src/Shared/` holds contracts, trait, subscriber, helper, providers. +- [ ] `make test` green (96 prior + new unit/functional tests). +- [ ] No destructive migration; no business entity moved; no inter-module import. + +## Notes for the next ticket (0.2 — Socle front) + +Le front consommera `/api/modules` + `/api/sidebar` via `useModules`/`useSidebar`, montera le shell `app/` + `shared/` et l'auto-détection des layers. Le filtrage par module deviendra réellement visible quand le 1er module (1.1 Core, puis 2.1 TimeTracking) déclarera sa clé `module` dans `config/sidebar.php`. diff --git a/docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md b/docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md new file mode 100644 index 0000000..b3c6842 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md @@ -0,0 +1,1690 @@ +# RBAC fin (LST-57 / 1.2) 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:** Porter le RBAC fin de Starseed dans le Module Core de Lesstime : permissions `module.resource[.sub].action`, entités `Role`/`Permission`, commande de synchronisation, `PermissionVoter`, sidebar filtrée par permission, et gestion front des rôles. + +**Architecture:** RBAC **additif** par-dessus l'auth Symfony existante (cf. Décision 1). On garde la colonne JSON `roles` + `ROLE_ADMIN`/`ROLE_USER` (login/JWT/MCP/sidebar #62 inchangés ; `ROLE_ADMIN` = bypass du voter) et on ajoute la couche RBAC fine : `Role`/`Permission` (Module Core), relations `rbacRoles`/`directPermissions` sur `User`, `getEffectivePermissions()`, `PermissionVoter`, `app:sync-permissions`, `app:seed-rbac`, sidebar gated par permission, front `usePermissions`. + +**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 — Nuxt 4 / Vue 3 / Pinia / TypeScript. Tests : PHPUnit 13. Docker : `php-lesstime-fpm` (user `www-data`), nginx port 8082, PG port 5435. + +## Global Constraints + +- `declare(strict_types=1)` en tête de tout fichier PHP. Symfony + PSR-12, hook pre-commit php-cs-fixer (ne pas lutter contre le reformat). +- Namespaces : back `App\Module\Core\...`, `App\Shared\...`. Front layer `frontend/modules/core/`. +- **Zéro régression auth** : après chaque phase touchant la sécurité (C, D, F), exécuter le bloc « Vérification login » ci-dessous → `login=204`, `/api/me=200`, `/_mcp=200`. +- **Migration additive uniquement** : `CREATE TABLE` des tables RBAC ; **aucun** `DROP`/`ALTER` destructif sur `user`/`roles`. `doctrine:migrations:diff` après doit être vide (hors dérive préexistante `messenger_messages`). +- PostgreSQL : noms de colonnes en minuscules dans le SQL brut ; `roles::text LIKE` pour les colonnes JSON. +- `config/reference.php` est auto-généré : **ne jamais le committer**. Untracked `.codex`, `bulettins/` : ignorer. +- Aucune mention de Claude/IA dans les commits. +- Commits : `(core) : ` (espace autour du `:`). +- Tests existants au départ : **120 verts**. Chaque phase ajoute ses tests et garde l'ensemble vert. + +## Vérification login (à exécuter après chaque phase back touchant User/sécurité) + +```bash +curl -s -i -X POST http://localhost:8082/api/login_check -H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' -D /tmp/h.txt -o /dev/null -w "login=%{http_code}\n" +BEARER=$(grep -i 'set-cookie: BEARER' /tmp/h.txt | sed -E 's/.*BEARER=([^;]+);.*/\1/') +curl -s -o /dev/null -w "me=%{http_code}\n" http://localhost:8082/api/me -H "Cookie: BEARER=$BEARER" +curl -s -o /dev/null -w "mcp=%{http_code}\n" -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" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"c","version":"1"}}}' +``` +Attendu : `login=204`, `me=200`, `mcp=200`. + +## Décisions de conception (actées, à valider PO a posteriori) + +1. **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`). Justification : login/JWT, `security.yaml` (`role_hierarchy`, `app_user_provider`), gate sidebar #62 (`roles: [ROLE_ADMIN]`), MCP `apiToken` reposent tous sur `getRoles()`/`roles` JSON ; les réécrire = régression auth à haut risque pour zéro bénéfice AC. On garde `getRoles()` tel quel ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Migration future vers `is_admin` possible si le PO le souhaite. +2. **`user_permission` (directPermissions) inclus** — fidélité Starseed : un user peut recevoir des permissions directes en plus de ses rôles. `getEffectivePermissions()` = union(rôles.permissions, directPermissions). +3. **Gestion de `ROLE_ADMIN` reste sur le PATCH user existant (`roles`), PAS sur l'endpoint RBAC** — l'endpoint `/api/users/{id}/rbac` ne gère que `rbacRoles` + `directPermissions`. Pas de gardes « dernier admin »/« auto-suicide » (elles concernent `is_admin`, absent ici) ; on conserve uniquement la garde anti-écrasement des collections (defense in depth). +4. **Pas de rôles métier seedés en 1.2** — seules les permissions `core.*` existent (les modules métier arrivent en 2.x). `app:seed-rbac` crée les rôles système `admin` et `user` (isSystem=true) sans matrice métier. Chaque module métier ajoutera ses permissions + (optionnel) ses rôles quand il sera livré. +5. **Pas de `Sites`** — Lesstime n'a pas de notion de site : on retire toutes les gardes/relations `sites.*`, `currentSite`, `bypass_scope` du portage Starseed. +6. **Entités `Role`/`Permission` sans Timestampable/Blamable** — alignées sur Starseed (métadonnées RBAC pures). + +--- + +## Phase A — Domaine RBAC : entités, relations, migration + +### Task 1: Entités `Permission` + `Role` + relations `User` + repositories + contrat + +**Files:** +- Create: `src/Module/Core/Domain/Entity/Permission.php` +- Create: `src/Module/Core/Domain/Entity/Role.php` +- Create: `src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` +- Create: `src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` +- Create: `src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` +- Create: `src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` +- Modify: `src/Module/Core/Domain/Entity/User.php` (relations `rbacRoles` + `directPermissions` + `getEffectivePermissions()`) +- Modify: `src/Shared/Domain/Contract/UserInterface.php` (ajout `getEffectivePermissions()`) +- Modify: `config/services.yaml` (alias repositories) +- Create: `tests/Unit/Module/Core/Domain/Entity/PermissionTest.php` +- Create: `tests/Unit/Module/Core/Domain/Entity/RoleTest.php` +- Modify: `tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` (stub `getEffectivePermissions()` dans l'anonyme implémentant `UserInterface`) + +**Interfaces:** +- Produces : `Permission { getId, getCode, getLabel, getModule, isOrphan, markOrphan(), revive(label, module), updateMetadata(label, module) }` ; `Role { getId, getCode, getLabel, getDescription, isSystem, getPermissions(): Collection, addPermission(Permission), removePermission(Permission), ensureDeletable() }` ; `User::getEffectivePermissions(): list`, `User::getRbacRoles(): Collection`, `addRbacRole`/`removeRbacRole`, `getDirectPermissions(): Collection`, `addDirectPermission`/`removeDirectPermission`. +- Repositories : `PermissionRepositoryInterface { findById(int): ?Permission, findByCode(string): ?Permission, findAll(): list, findAllCodes(): list, save(Permission): void }` ; `RoleRepositoryInterface { findById(int): ?Role, findByCode(string): ?Role, findAll(): list, save(Role): void }`. + +- [ ] **Step 1: Écrire les tests unitaires des entités** + +`tests/Unit/Module/Core/Domain/Entity/PermissionTest.php` : +```php +getCode()); + self::assertSame('Voir les utilisateurs', $p->getLabel()); + self::assertSame('core', $p->getModule()); + self::assertFalse($p->isOrphan()); + } + + public function testCodeMustContainADot(): void + { + $this->expectException(\InvalidArgumentException::class); + new Permission('coreusersview', 'x', 'core'); + } + + public function testCodeCannotBeEmpty(): void + { + $this->expectException(\InvalidArgumentException::class); + new Permission('', 'x', 'core'); + } + + public function testLabelCannotBeEmpty(): void + { + $this->expectException(\InvalidArgumentException::class); + new Permission('core.users.view', '', 'core'); + } + + public function testModuleCannotBeEmpty(): void + { + $this->expectException(\InvalidArgumentException::class); + new Permission('core.users.view', 'x', ''); + } + + public function testMarkOrphanAndRevive(): void + { + $p = new Permission('core.users.view', 'Voir', 'core'); + $p->markOrphan(); + self::assertTrue($p->isOrphan()); + $p->revive('Voir maj', 'core'); + self::assertFalse($p->isOrphan()); + self::assertSame('Voir maj', $p->getLabel()); + } +} +``` + +`tests/Unit/Module/Core/Domain/Entity/RoleTest.php` : +```php +getCode()); + self::assertSame('Bureau', $r->getLabel()); + self::assertFalse($r->isSystem()); + self::assertCount(0, $r->getPermissions()); + } + + public function testCodeMustBeSnakeCase(): void + { + $this->expectException(\InvalidArgumentException::class); + new Role('Bureau Commercial', 'x'); + } + + public function testAddRemovePermission(): void + { + $r = new Role('bureau', 'Bureau'); + $p = new Permission('core.users.view', 'Voir', 'core'); + $r->addPermission($p); + self::assertCount(1, $r->getPermissions()); + $r->addPermission($p); // idempotent + self::assertCount(1, $r->getPermissions()); + $r->removePermission($p); + self::assertCount(0, $r->getPermissions()); + } + + public function testSystemRoleCannotBeDeleted(): void + { + $r = new Role('admin', 'Administrateur', null, true); + $this->expectException(SystemRoleDeletionException::class); + $r->ensureDeletable(); + } + + public function testNonSystemRoleIsDeletable(): void + { + $r = new Role('bureau', 'Bureau'); + $r->ensureDeletable(); + self::assertFalse($r->isSystem()); + } +} +``` + +- [ ] **Step 2: Lancer les tests, vérifier l'échec** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/Domain/Entity/` +Expected: FAIL (classes inexistantes). + +- [ ] **Step 3: Créer l'exception** + +`src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : +```php + ['permission:read']], + security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')", +)] +class Permission +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['permission:read', 'role:read'])] + private ?int $id = null; + + #[ORM\Column(length: 255, unique: true)] + #[Groups(['permission:read', 'role:read'])] + private string $code; + + #[ORM\Column(length: 255)] + #[Groups(['permission:read', 'role:read'])] + private string $label; + + #[ORM\Column(length: 100)] + #[Groups(['permission:read', 'role:read'])] + private string $module; + + #[ORM\Column] + #[Groups(['permission:read'])] + private bool $orphan = false; + + public function __construct(string $code, string $label, string $module) + { + $code = trim($code); + $label = trim($label); + $module = trim($module); + + if ('' === $code || !str_contains($code, '.')) { + throw new \InvalidArgumentException(sprintf('Code de permission invalide : "%s" (attendu module.resource.action).', $code)); + } + if ('' === $label) { + throw new \InvalidArgumentException('Le libellé de permission ne peut pas être vide.'); + } + if ('' === $module) { + throw new \InvalidArgumentException('Le module de permission ne peut pas être vide.'); + } + + $this->code = $code; + $this->label = $label; + $this->module = $module; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getModule(): string + { + return $this->module; + } + + public function isOrphan(): bool + { + return $this->orphan; + } + + public function markOrphan(): void + { + $this->orphan = true; + } + + public function revive(string $label, string $module): void + { + $this->orphan = false; + $this->updateMetadata($label, $module); + } + + public function updateMetadata(string $label, string $module): void + { + $this->label = $label; + $this->module = $module; + } +} +``` + +- [ ] **Step 5: Créer `Role`** + +`src/Module/Core/Domain/Entity/Role.php` : +```php + ['role:read']], + denormalizationContext: ['groups' => ['role:write']], +)] +class Role +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['role:read'])] + private ?int $id = null; + + #[ORM\Column(length: 100, unique: true)] + #[Groups(['role:read', 'role:write'])] + private string $code; + + #[ORM\Column(length: 255)] + #[Groups(['role:read', 'role:write'])] + private string $label; + + #[ORM\Column(type: 'text', nullable: true)] + #[Groups(['role:read', 'role:write'])] + private ?string $description; + + #[ORM\Column(name: 'is_system')] + #[Groups(['role:read'])] + private bool $isSystem; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'role_permission')] + #[Groups(['role:read', 'role:write'])] + private Collection $permissions; + + public function __construct(string $code, string $label, ?string $description = null, bool $isSystem = false) + { + if (1 !== preg_match('/^[a-z][a-z0-9_]*$/', $code)) { + throw new \InvalidArgumentException(sprintf('Code de rôle invalide : "%s" (attendu snake_case).', $code)); + } + if ('' === trim($label)) { + throw new \InvalidArgumentException('Le libellé de rôle ne peut pas être vide.'); + } + + $this->code = $code; + $this->label = $label; + $this->description = $description; + $this->isSystem = $isSystem; + $this->permissions = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCode(): string + { + return $this->code; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } + + public function isSystem(): bool + { + return $this->isSystem; + } + + /** + * @return Collection + */ + public function getPermissions(): Collection + { + return $this->permissions; + } + + public function addPermission(Permission $permission): void + { + if (!$this->permissions->contains($permission)) { + $this->permissions->add($permission); + } + } + + public function removePermission(Permission $permission): void + { + $this->permissions->removeElement($permission); + } + + public function ensureDeletable(): void + { + if ($this->isSystem) { + throw new SystemRoleDeletionException($this->code); + } + } +} +``` + +- [ ] **Step 6: Ajouter les relations RBAC + `getEffectivePermissions()` à `User`** + +Dans `src/Module/Core/Domain/Entity/User.php` : ajouter les imports `use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection;` (si absents) et, dans la classe : +```php + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_role')] + #[Groups(['user:rbac:read'])] + private Collection $rbacRoles; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'user_permission')] + #[Groups(['user:rbac:read'])] + private Collection $directPermissions; +``` +> ⚠️ Initialiser les deux collections dans le constructeur de `User` (s'il en existe un ; sinon en ajouter un `public function __construct() { $this->rbacRoles = new ArrayCollection(); $this->directPermissions = new ArrayCollection(); }`. Vérifier d'abord s'il y a déjà un constructeur — `createdAt` est posé ailleurs ? Lire l'entité. Si pas de constructeur, en créer un n'initialisant QUE ces deux collections). + +Ajouter les méthodes : +```php + /** + * @return Collection + */ + public function getRbacRoles(): Collection + { + return $this->rbacRoles; + } + + public function addRbacRole(Role $role): void + { + if (!$this->rbacRoles->contains($role)) { + $this->rbacRoles->add($role); + } + } + + public function removeRbacRole(Role $role): void + { + $this->rbacRoles->removeElement($role); + } + + /** + * @return Collection + */ + public function getDirectPermissions(): Collection + { + return $this->directPermissions; + } + + public function addDirectPermission(Permission $permission): void + { + if (!$this->directPermissions->contains($permission)) { + $this->directPermissions->add($permission); + } + } + + public function removeDirectPermission(Permission $permission): void + { + $this->directPermissions->removeElement($permission); + } + + /** + * Permissions effectives = union (rôles RBAC → permissions) ∪ (permissions directes), triée, dédupliquée. + * + * @return list + */ + #[Groups(['me:read', 'user:rbac:read'])] + public function getEffectivePermissions(): array + { + $codes = []; + foreach ($this->rbacRoles as $role) { + foreach ($role->getPermissions() as $permission) { + $codes[$permission->getCode()] = true; + } + } + foreach ($this->directPermissions as $permission) { + $codes[$permission->getCode()] = true; + } + $keys = array_keys($codes); + sort($keys); + + return $keys; + } +``` +Ajouter les imports `use App\Module\Core\Domain\Entity\Role;` n'est pas nécessaire (même namespace) ; `Permission` non plus. Vérifier la présence de `use Symfony\Component\Serializer\Annotation\Groups;` (déjà là vu les Groups existants). + +- [ ] **Step 7: Enrichir le contrat `UserInterface`** + +Dans `src/Shared/Domain/Contract/UserInterface.php`, ajouter : +```php + /** @return list */ + public function getEffectivePermissions(): array; +``` +Puis ajouter le stub dans l'anonyme du test `tests/Unit/Shared/Doctrine/TimestampableBlamableSubscriberTest.php` qui implémente `UserInterface` : +```php + public function getEffectivePermissions(): array + { + return []; + } +``` + +- [ ] **Step 8: Créer les repositories + alias services** + +`src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : +```php + */ + public function findAll(): array; + + /** @return list */ + public function findAllCodes(): array; + + public function save(Permission $permission): void; +} +``` + +`src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : +```php + */ + public function findAll(): array; + + public function save(Role $role): void; +} +``` + +`src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : +```php + + */ +final class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Permission::class); + } + + public function findById(int $id): ?Permission + { + return $this->find($id); + } + + public function findByCode(string $code): ?Permission + { + return $this->findOneBy(['code' => $code]); + } + + /** @return list */ + public function findAll(): array + { + return array_values($this->findBy([])); + } + + /** @return list */ + public function findAllCodes(): array + { + /** @var list $rows */ + $rows = $this->createQueryBuilder('p')->select('p.code')->getQuery()->getArrayResult(); + + return array_map(static fn (array $r): string => $r['code'], $rows); + } + + public function save(Permission $permission): void + { + $this->getEntityManager()->persist($permission); + } +} +``` + +`src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : +```php + + */ +final class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Role::class); + } + + public function findById(int $id): ?Role + { + return $this->find($id); + } + + public function findByCode(string $code): ?Role + { + return $this->findOneBy(['code' => $code]); + } + + /** @return list */ + public function findAll(): array + { + return array_values($this->findBy([])); + } + + public function save(Role $role): void + { + $this->getEntityManager()->persist($role); + } +} +``` + +Dans `config/services.yaml`, sous les alias existants (à côté de `UserRepositoryInterface`) : +```yaml + App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository' + App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository' +``` + +- [ ] **Step 9: Lancer les tests unitaires entités** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/Domain/Entity/` +Expected: PASS. + +- [ ] **Step 10: Générer + appliquer la migration additive** + +Run: `docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff` +Vérifier que le `up()` ne contient QUE des `CREATE TABLE permission`, `CREATE TABLE "role"`, `CREATE TABLE role_permission`, `CREATE TABLE user_role`, `CREATE TABLE user_permission` (+ index + FK), **aucun** `DROP`/`ALTER` sur `"user"` ou `messenger_messages`. Si la dérive `messenger_messages` est mélangée, éditer la migration pour ne garder que les tables RBAC. +Ajouter les `COMMENT ON COLUMN` pour les colonnes métier (code, label, module, orphan, is_system, description) dans le `up()` (convention projet, cf. ColumnCommentsCatalog). +Run: `docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction` +Puis recharger les fixtures de test : `make db-reset` (ou équivalent test) — vérifier que ça passe. + +- [ ] **Step 11: Gate + commit** + +Run: `docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate` → Mapping OK. +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert (120 + 11). +Bloc « Vérification login ». +```bash +make php-cs-fixer-allow-risky +git add -A -- src config tests migrations +git reset -- config/reference.php +git commit -m "feat(core) : add rbac role and permission entities with user relations" +``` + +--- + +## Phase B — Agrégation des permissions + commande de synchronisation + +### Task 2: `ModuleRegistry::permissions()` + `CoreModule::permissions()` finalisé + `app:sync-permissions` + +**Files:** +- Modify: `src/Shared/Domain/Module/ModuleRegistry.php` (méthode `permissions()`) +- Modify: `src/Module/Core/CoreModule.php` (permissions Core définitives) +- Create: `src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` +- Create: `tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php` +- Create: `tests/Functional/Module/Core/SyncPermissionsCommandTest.php` + +**Interfaces:** +- Consumes : `ModuleInterface::permissions(): list` (existant), `PermissionRepositoryInterface`. +- Produces : `ModuleRegistry::permissions(array $moduleClasses): list` (agrège + injecte `module` = `$class::id()`, valide préfixe). Commande `app:sync-permissions`. + +- [ ] **Step 1: Test de `ModuleRegistry::permissions()`** + +`tests/Unit/Shared/Module/ModuleRegistryPermissionsTest.php` : +```php + $moduleClasses + * + * @return list + */ + public static function permissions(array $moduleClasses): array + { + $out = []; + foreach ($moduleClasses as $moduleClass) { + if (!is_a($moduleClass, ModuleInterface::class, true)) { + continue; + } + $moduleId = $moduleClass::id(); + foreach ($moduleClass::permissions() as $perm) { + $code = $perm['code']; + if (!str_starts_with($code, $moduleId.'.')) { + throw new \InvalidArgumentException(sprintf('Permission "%s" du module "%s" doit être préfixée par "%s.".', $code, $moduleId, $moduleId)); + } + $out[] = ['code' => $code, 'label' => $perm['label'], 'module' => $moduleId]; + } + } + + return $out; + } +``` + +- [ ] **Step 4: Finaliser `CoreModule::permissions()`** + +Remplacer le stub par les permissions Core RBAC (alignées sur Starseed, périmètre Lesstime) : +```php + public static function permissions(): array + { + return [ + ['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'], + ['code' => 'core.users.manage', 'label' => 'Gérer les utilisateurs (créer, éditer, supprimer)'], + ['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'], + ['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'], + ['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'], + ]; + } +``` +> Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` si une assertion fige les anciens codes (`core.user.read`, etc.). + +- [ ] **Step 5: Test fonctionnel de la commande** + +`tests/Functional/Module/Core/SyncPermissionsCommandTest.php` : +```php +find('app:sync-permissions')); + $tester->execute([]); + $tester->assertCommandIsSuccessful(); + + $repo = self::getContainer()->get(PermissionRepositoryInterface::class); + self::assertNotNull($repo->findByCode('core.users.manage')); + self::assertContains('core.roles.manage', $repo->findAllCodes()); + } +} +``` + +- [ ] **Step 6: Lancer, vérifier l'échec** (commande inexistante). + +- [ ] **Step 7: Implémenter `app:sync-permissions`** + +`src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : +```php + $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + // Phase 1 : permissions désirées (code => {code,label,module}). + $desired = []; + foreach (ModuleRegistry::permissions($moduleClasses) as $perm) { + $desired[$perm['code']] = $perm; + } + + // Phase 2 : upsert. + $existing = []; + foreach ($this->permissions->findAll() as $permission) { + $existing[$permission->getCode()] = $permission; + } + + $added = $updated = $revived = 0; + foreach ($desired as $code => $perm) { + $entity = $existing[$code] ?? null; + if (null === $entity) { + $this->permissions->save(new Permission($perm['code'], $perm['label'], $perm['module'])); + ++$added; + + continue; + } + if ($entity->isOrphan()) { + $entity->revive($perm['label'], $perm['module']); + ++$revived; + } elseif ($entity->getLabel() !== $perm['label'] || $entity->getModule() !== $perm['module']) { + $entity->updateMetadata($perm['label'], $perm['module']); + ++$updated; + } + } + + // Phase 3 : orphelines (existantes absentes des désirées). + $orphaned = 0; + foreach ($existing as $code => $entity) { + if (!isset($desired[$code]) && !$entity->isOrphan()) { + $entity->markOrphan(); + ++$orphaned; + } + } + + $this->em->flush(); + + $io->success(sprintf('Permissions synchronisées : %d ajoutées, %d mises à jour, %d réactivées, %d orphelines. Total désirées : %d.', $added, $updated, $revived, $orphaned, \count($desired))); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 8: Tests + commit** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert. +```bash +make php-cs-fixer-allow-risky +git add -A -- src tests +git commit -m "feat(core) : aggregate module permissions and add sync-permissions command" +``` + +--- + +## Phase C — PermissionVoter + exposition `/api/me` + +### Task 3: `PermissionVoter` + permissions effectives dans `/api/me` + +**Files:** +- Create: `src/Module/Core/Infrastructure/Security/PermissionVoter.php` +- Create: `tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php` +- Modify (vérifier) : `src/Module/Core/Domain/Entity/User.php` — `getEffectivePermissions()` déjà dans le groupe `me:read` (Phase A Step 6). Confirmer. + +**Interfaces:** +- Consumes : `User::getEffectivePermissions()`, `User::getRoles()`. +- Produces : voter répondant à `is_granted('module.resource.action')`. + +- [ ] **Step 1: Test du voter** + +`tests/Unit/Module/Core/Infrastructure/Security/PermissionVoterTest.php` : +```php +getRoles()); + } + + public function testAbstainsOnNonRbacAttributes(): void + { + $voter = new PermissionVoter(); + $user = new User(); + self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token($user), null, ['ROLE_ADMIN'])); + self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token($user), null, ['IS_AUTHENTICATED_FULLY'])); + } + + public function testGrantsWhenUserHasPermissionViaRole(): void + { + $voter = new PermissionVoter(); + $role = new Role('bureau', 'Bureau'); + $role->addPermission(new Permission('core.users.view', 'Voir', 'core')); + $user = new User(); + $user->addRbacRole($role); + + self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token($user), null, ['core.users.view'])); + self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token($user), null, ['core.users.manage'])); + } + + public function testAdminBypassesViaRole(): void + { + $voter = new PermissionVoter(); + $user = new User(); + $user->setRoles(['ROLE_ADMIN']); + + self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token($user), null, ['core.users.manage'])); + } +} +``` + +- [ ] **Step 2: Lancer, vérifier l'échec.** + +- [ ] **Step 3: Implémenter le voter** + +`src/Module/Core/Infrastructure/Security/PermissionVoter.php` : +```php + + */ +final class PermissionVoter extends Voter +{ + private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/'; + + protected function supports(string $attribute, mixed $subject): bool + { + return 1 === preg_match(self::PATTERN, $attribute); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool + { + $user = $token->getUser(); + if (!$user instanceof User) { + return false; + } + + // ROLE_ADMIN = bypass total (cf. Décision 1). + if (in_array('ROLE_ADMIN', $user->getRoles(), true)) { + return true; + } + + return in_array($attribute, $user->getEffectivePermissions(), true); + } +} +``` +> Le voter est auto-enregistré (autoconfigure). Aucun ajout `services.yaml` nécessaire. + +- [ ] **Step 4: Tests + login + vérif `/api/me`** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert. +Bloc « Vérification login », puis vérifier que `/api/me` expose `effectivePermissions` : +```bash +curl -s http://localhost:8082/api/me -H "Cookie: BEARER=$BEARER" | grep -o "effectivePermissions" && echo "OK champ présent" +``` +> `alice` (ROLE_USER, sans rôle RBAC) renverra `effectivePermissions: []` — normal à ce stade (pas de rôle attribué). + +- [ ] **Step 5: Commit** + +```bash +make php-cs-fixer-allow-risky +git add -A -- src tests +git commit -m "feat(core) : add permission voter and expose effective permissions on /api/me" +``` + +--- + +## Phase D — API Platform : Role + Permission + processors + +### Task 4: ApiResources Role/Permission (déjà sur entités) + `RoleProcessor` + endpoint RBAC user + `UserRbacProcessor` + +**Files:** +- Create: `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php` +- Create: `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` +- Modify: `src/Module/Core/Domain/Entity/User.php` (opérations RBAC `Get`/`Patch` sur `/api/users/{id}/rbac` + groupes `user:rbac:read`/`user:rbac:write`) +- Create: `tests/Functional/Module/Core/RoleApiTest.php` +- Create: `tests/Functional/Module/Core/UserRbacApiTest.php` + +**Interfaces:** +- Consumes : `RoleRepositoryInterface`, `Role::ensureDeletable()`, `User` collections. +- Produces : `POST/PATCH/DELETE /api/roles`, `GET/PATCH /api/users/{id}/rbac`. + +- [ ] **Step 1: `RoleProcessor`** (immuabilité du code + refus delete système) + +`src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php` : +```php + + */ +final readonly class RoleProcessor implements ProcessorInterface +{ + public function __construct(private EntityManagerInterface $em) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?Role + { + \assert($data instanceof Role); + + if ($operation instanceof DeleteOperationInterface) { + try { + $data->ensureDeletable(); + } catch (\DomainException $e) { + throw new AccessDeniedHttpException($e->getMessage(), $e); + } + $this->em->remove($data); + $this->em->flush(); + + return null; + } + + $this->em->persist($data); + $this->em->flush(); + + return $data; + } +} +``` +> Le code étant `role:write` mais immuable : API Platform mappe le `code` à la création (POST). En PATCH, le `code` reste dans `role:write` — pour le rendre immuable, vérifier dans un test que le PATCH du code n'a pas d'effet (l'entité n'a pas de `setCode()`, donc le denormalizer ne peut pas l'écraser → immuabilité structurelle). Confirmer l'absence de `setCode()` dans `Role`. ✅ (non défini en Phase A). + +- [ ] **Step 2: Endpoint RBAC sur `User` + `UserRbacProcessor`** + +Dans `src/Module/Core/Domain/Entity/User.php`, ajouter aux `operations` de l'`ApiResource` : +```php + new Get( + uriTemplate: '/users/{id}/rbac', + security: "is_granted('core.users.manage')", + normalizationContext: ['groups' => ['user:rbac:read']], + ), + new Patch( + uriTemplate: '/users/{id}/rbac', + security: "is_granted('core.users.manage')", + normalizationContext: ['groups' => ['user:rbac:read']], + denormalizationContext: ['groups' => ['user:rbac:write']], + processor: UserRbacProcessor::class, + ), +``` +Ajouter l'import `use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;`. +Ajouter les setters de collections en denormalization (groupe `user:rbac:write`) : exposer `rbacRoles` et `directPermissions` en écriture. Pour cela, ajouter le groupe `user:rbac:write` sur les deux propriétés (en plus de `user:rbac:read`) : +```php + #[Groups(['user:rbac:read', 'user:rbac:write'])] + private Collection $rbacRoles; + ... + #[Groups(['user:rbac:read', 'user:rbac:write'])] + private Collection $directPermissions; +``` +> API Platform écrit les collections M2M via les adders/removers `addRbacRole`/`removeRbacRole` et `addDirectPermission`/`removeDirectPermission` (déjà définis Phase A). + +`src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` (garde anti-écrasement des collections absentes du payload) : +```php + + */ +final readonly class UserRbacProcessor implements ProcessorInterface +{ + public function __construct(private EntityManagerInterface $em) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User + { + \assert($data instanceof User); + + // Defense in depth : si une collection n'était pas dans le payload JSON, API Platform + // la réinstancie vide → on restaure le snapshot Doctrine pour éviter l'effacement silencieux. + $previous = $context['previous_data'] ?? null; + if ($previous instanceof User) { + $this->restoreIfEmptiedByAbsence($data->getRbacRoles(), $previous->getRbacRoles()); + $this->restoreIfEmptiedByAbsence($data->getDirectPermissions(), $previous->getDirectPermissions()); + } + + $this->em->persist($data); + $this->em->flush(); + + return $data; + } + + /** + * @param iterable $current + * @param iterable $previous + */ + private function restoreIfEmptiedByAbsence(mixed $current, mixed $previous): void + { + // Si la collection courante est "propre" (non modifiée par le denormalizer) mais vidée, + // on ne touche à rien : la mutation explicite passe par add/remove. + if ($current instanceof PersistentCollection && !$current->isDirty()) { + return; + } + // NB : la restauration fine est laissée à l'exécutant si un test prouve l'écrasement ; + // en pratique, exposer les collections en user:rbac:write avec les adders/removers suffit. + } +} +``` +> ⚠️ NOTE EXÉCUTANT : commencer SIMPLE — exposer `rbacRoles`/`directPermissions` en `user:rbac:write` (avec adders/removers) gère nativement les mutations via IRIs. Écrire d'abord le test `UserRbacApiTest` (Step 4) ; si et seulement si il prouve un écrasement de collection lors d'un PATCH partiel, implémenter la restauration depuis `$context['previous_data']`. Sinon, le `UserRbacProcessor` peut se réduire à persist+flush. Ne pas sur-architecturer. + +- [ ] **Step 3: Tests fonctionnels Role** + +`tests/Functional/Module/Core/RoleApiTest.php` : créer un user admin authentifié (helper login JWT comme les autres tests fonctionnels du projet — s'inspirer de `tests/Functional/...` existants), puis : +- `GET /api/roles` en tant qu'admin → 200. +- `GET /api/roles` non authentifié → 401. +- `POST /api/roles` `{code:'bureau', label:'Bureau'}` en admin → 201. +- `DELETE` d'un rôle système (`admin`, seedé en Phase E — ou créé inline `isSystem`) → 403. +> S'aligner sur le pattern d'auth des tests fonctionnels existants (cookie BEARER via `/login_check` ou client API Platform avec token). LIRE un test fonctionnel existant avant d'écrire celui-ci. + +- [ ] **Step 4: Tests fonctionnels User RBAC** + +`tests/Functional/Module/Core/UserRbacApiTest.php` : +- `GET /api/users/{id}/rbac` en admin → 200, contient `rbacRoles`, `directPermissions`, `effectivePermissions`. +- `PATCH /api/users/{id}/rbac` attribuant un rôle (IRI) → 200, le rôle apparaît dans `rbacRoles`. +- `PATCH` ne touchant qu'un champ → vérifier que l'autre collection n'est PAS vidée (le test qui décide si `UserRbacProcessor` a besoin de la restauration). + +- [ ] **Step 5: Lancer + login + commit** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert. +Bloc « Vérification login ». +```bash +make php-cs-fixer-allow-risky +git add -A -- src tests +git commit -m "feat(core) : expose role and user-rbac api endpoints with processors" +``` + +--- + +## Phase E — Seed RBAC (rôles système) + +### Task 5: `RbacSeeder` + `app:seed-rbac` + intégration fixtures + +**Files:** +- Create: `src/Module/Core/Application/Rbac/RbacSeeder.php` +- Create: `src/Module/Core/Infrastructure/Console/SeedRbacCommand.php` +- Create: `src/Module/Core/Domain/Security/SystemRoles.php` +- Modify: `src/DataFixtures/AppFixtures.php` (appeler le seed des rôles système après sync, OU documenter l'appel via make) +- Create: `tests/Functional/Module/Core/SeedRbacCommandTest.php` + +**Interfaces:** +- Consumes : `RoleRepositoryInterface`, `EntityManagerInterface`. +- Produces : `RbacSeeder::ensureSystemRoles(): void` (idempotent), commande `app:seed-rbac`. + +- [ ] **Step 1: `SystemRoles`** + +`src/Module/Core/Domain/Security/SystemRoles.php` : +```php +find('app:seed-rbac')); + + $tester->execute([]); + $tester->assertCommandIsSuccessful(); + $tester->execute([]); // idempotent + $tester->assertCommandIsSuccessful(); + + $repo = self::getContainer()->get(RoleRepositoryInterface::class); + $admin = $repo->findByCode(SystemRoles::ADMIN_CODE); + self::assertNotNull($admin); + self::assertTrue($admin->isSystem()); + self::assertNotNull($repo->findByCode(SystemRoles::USER_CODE)); + } +} +``` + +- [ ] **Step 3: Lancer, vérifier l'échec.** + +- [ ] **Step 4: `RbacSeeder`** + +`src/Module/Core/Application/Rbac/RbacSeeder.php` : +```php +ensureRole(SystemRoles::ADMIN_CODE, 'Administrateur', 'Accès complet (bypass RBAC).'); + $this->ensureRole(SystemRoles::USER_CODE, 'Utilisateur', 'Rôle de base sans permission spécifique.'); + $this->em->flush(); + } + + private function ensureRole(string $code, string $label, string $description): void + { + if (null !== $this->roles->findByCode($code)) { + return; + } + $this->roles->save(new Role($code, $label, $description, true)); + } +} +``` + +- [ ] **Step 5: `app:seed-rbac`** + +`src/Module/Core/Infrastructure/Console/SeedRbacCommand.php` : +```php +seeder->ensureSystemRoles(); + $io->success('Rôles système RBAC seedés (admin, user).'); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 6: Intégrer au cycle fixtures** + +LIRE `src/DataFixtures/AppFixtures.php`. Après le chargement des users, appeler le seed RBAC (les permissions doivent exister → `app:sync-permissions` AVANT). Deux options selon le pattern projet : +- (a) injecter `RbacSeeder` dans `AppFixtures` et appeler `ensureSystemRoles()` en fin de `load()` (les permissions Core sont synchronisées par un hook séparé) ; OU +- (b) documenter dans le `Makefile`/README que `make db-reset` enchaîne `fixtures:load` puis `app:sync-permissions` puis `app:seed-rbac`. +> Choisir (a) si `AppFixtures` peut injecter des services (DependentFixture/service) ; sinon (b). Vérifier que `make db-reset` laisse une base cohérente (rôles + permissions présents). NE PAS attacher de matrice métier (Décision 4). + +- [ ] **Step 7: Tests + commit** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert. +```bash +make php-cs-fixer-allow-risky +git add -A -- src tests +git commit -m "feat(core) : add rbac seeder and seed-rbac command for system roles" +``` + +--- + +## Phase F — Sidebar filtrée par permission + +### Task 6: `SidebarFilter` + `SidebarProvider` + `config/sidebar.php` gated par permission + +**Files:** +- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (clé `permission` sur section + item) +- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (passe les permissions effectives) +- Modify: `config/sidebar.php` (permissions sur les items admin) +- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (cas permission) +- Modify (éventuel): `tests/Functional/Shared/SidebarEndpointTest.php` + +**Interfaces:** +- Consumes : `User::getEffectivePermissions()`, `User::getRoles()`. +- Produces : `SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = [], $activePermissions = [])`. + +- [ ] **Step 1: Étendre le test `SidebarFilterTest`** + +Ajouter des cas : une section/item avec `'permission' => 'core.users.view'` est masqué si la permission n'est pas dans `$activePermissions`, visible sinon. Un item sans `permission` reste visible (rétrocompat). Combinaison avec `roles` et `module` inchangée. +```php + public function testItemHiddenWhenPermissionMissing(): void + { + $sections = [[ + 'label' => 's', 'icon' => 'i', + 'items' => [ + ['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view'], + ['label' => 'b', 'to' => '/b', 'icon' => 'i'], + ], + ]]; + $out = SidebarFilter::filter($sections, [], [], []); + self::assertCount(1, $out['sections'][0]['items']); + self::assertSame('/b', $out['sections'][0]['items'][0]['to']); + } + + public function testItemVisibleWhenPermissionGranted(): void + { + $sections = [[ + 'label' => 's', 'icon' => 'i', + 'items' => [['label' => 'a', 'to' => '/a', 'icon' => 'i', 'permission' => 'core.users.view']], + ]]; + $out = SidebarFilter::filter($sections, [], [], ['core.users.view']); + self::assertCount(1, $out['sections'][0]['items']); + } +``` + +- [ ] **Step 2: Lancer, vérifier l'échec** (signature à 3 args). + +- [ ] **Step 3: Étendre `SidebarFilter`** + +Ajouter le paramètre `array $activePermissions = []` à `filter()`. Mettre à jour la docblock des types (`permission?:string` sur section et item). Après le gate de rôle (section et item), ajouter le gate de permission : +```php + // Gate de permission au niveau section. + if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) { + continue; + } +``` +et pour l'item, avant le filtrage module : +```php + if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) { + continue; + } +``` +Helper : +```php + /** + * @param list $activePermissions + */ + private static function permissionSatisfied(?string $required, array $activePermissions): bool + { + if (null === $required || '' === $required) { + return true; + } + + return in_array($required, $activePermissions, true); + } +``` + +- [ ] **Step 4: Passer les permissions dans `SidebarProvider`** + +Dans `provide()`, après `$roles = ...` : +```php + $permissions = ($user instanceof \App\Module\Core\Domain\Entity\User) ? $user->getEffectivePermissions() : []; + + $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions); +``` +> Pour éviter le couplage dur au concret, préférer le contrat : `$user` peut être typé `UserInterface` (qui a désormais `getEffectivePermissions()`). Utiliser `$permissions = method_exists($user, 'getEffectivePermissions') ? $user->getEffectivePermissions() : [];` OU, plus propre, instancier-check sur `App\Shared\Domain\Contract\UserInterface`. Choisir le check sur le contrat Shared. + +- [ ] **Step 5: Ajouter les permissions dans `config/sidebar.php`** + +Sur la section admin, garder le gate `roles: [ROLE_ADMIN]` (rétrocompat) ET, en complément, ajouter une `permission` sur l'item Administration le cas échéant. Comme ROLE_ADMIN bypasse déjà tout (Décision 1), garder la section admin sur `roles` est suffisant ; on ajoute `permission` seulement si on veut donner accès à des non-admins porteurs de `core.users.view`/`core.roles.view`. Décider : ajouter `'permission' => 'core.users.view'` sur l'item `administration` pour permettre l'accès aux gestionnaires non-admin. Documenter dans le commentaire d'en-tête de `config/sidebar.php` la nouvelle clé `permission`. +> Mettre à jour le commentaire d'en-tête : « `permission` (section/item masqué si la permission effective absente — le RBAC fin) ». + +- [ ] **Step 6: Tests + login + commit** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` → vert. +Bloc « Vérification login » + vérifier `/api/sidebar` : `admin` voit Administration, `alice` (ROLE_USER sans permission) ne voit pas l'item gardé par permission. +```bash +make php-cs-fixer-allow-risky +git add -A -- src config tests +git commit -m "feat(core) : gate sidebar by effective permissions" +``` + +--- + +## Phase G — Front : composable `usePermissions` + gestion des rôles + +### Task 7: `usePermissions`, type user étendu, page admin rôles + +**Files:** +- Create: `frontend/modules/core/composables/usePermissions.ts` +- Modify: type de l'utilisateur courant (chercher `frontend/services/dto/` ou store auth) — ajouter `effectivePermissions: string[]` +- Create: `frontend/modules/core/services/roles.ts` + `frontend/modules/core/services/permissions.ts` +- Create: `frontend/modules/core/pages/admin/roles.vue` (gestion des rôles) — OU onglet dans l'admin existant +- Modify: `frontend/i18n/locales/fr.json` (clés `admin.roles.*`) + +> ⚠️ Le front Lesstime n'a pas encore de page de gestion de rôles. LIRE d'abord la structure (`frontend/modules/core/`, `frontend/stores/auth.ts`, `frontend/services/`, `frontend/pages/admin.vue` et ses onglets `Admin*Tab`). Reproduire le pattern existant (onglet `AdminUserTab` etc.). Le composable et le type sont le cœur de l'AC front ; la page de gestion peut être un onglet supplémentaire dans `admin.vue`. + +**Interfaces:** +- Consumes : `/api/me.effectivePermissions`, `/api/roles`, `/api/permissions`. +- Produces : `usePermissions(): { can(code), canAny(codes), canAll(codes) }`. + +- [ ] **Step 1: Étendre le type user + le store auth** + +LIRE le store `frontend/stores/auth.ts` (ou `shared/stores/auth.ts`) et le DTO user. Ajouter `effectivePermissions: string[]` au type, et s'assurer que le payload `/api/me` (qui l'expose désormais, Phase C) est bien stocké. Si le type a `roles: string[]`, garder. + +- [ ] **Step 2: Composable `usePermissions`** + +`frontend/modules/core/composables/usePermissions.ts` : +```ts +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 } +} +``` +> Le dossier `modules/core/composables` est auto-importé (scan `readdirSync('modules/')` → `imports.dirs`, cf. LST-62). `useAuthStore` est auto-importé. + +- [ ] **Step 3: Services roles/permissions** + +`frontend/modules/core/services/roles.ts` et `permissions.ts` : 1 service par ressource, via `useApi()` (pattern projet — LIRE `frontend/services/users.ts` comme modèle). Exposer `list()`, `create()`, `update()`, `remove()` pour roles ; `list()` pour permissions. Gérer la pagination via le pattern projet (ces collections sont bornées → `paginationEnabled: false` sur le `GetCollection` côté back, OU `fetchAllHydra`). Vérifier : si les collections Role/Permission peuvent dépasser 30 items, ajouter `paginationEnabled: false` sur leur `GetCollection` (cf. CLAUDE.md piège `extractHydraMembers`). Pour Lesstime, `permission` ≈ une douzaine de codes → borné ; `role` ≈ qqs unités → borné. Ajouter `paginationEnabled: false` sur les `GetCollection` de `Role` et `Permission` (entités, Phase A/D) pour fiabiliser `extractHydraMembers`. + +- [ ] **Step 4: Page / onglet de gestion des rôles** + +Ajouter un onglet `AdminRoleTab` dans `frontend/pages/admin.vue` (ou `modules/core`), listant les rôles, permettant création/édition (label, description, permissions cochées depuis `/api/permissions` groupées par module) et suppression (désactivée pour `isSystem`). Réserver l'affichage via `v-if="can('core.roles.view')"`. LIRE un `Admin*Tab` existant pour le style. + +- [ ] **Step 5: i18n** + +Ajouter dans `frontend/i18n/locales/fr.json` les clés `admin.roles.*` (titre, colonnes, actions, messages). Fusionner dans les namespaces existants (ne pas dupliquer une clé racine). + +- [ ] **Step 6: Gate front + smoke** + +Run: `cd frontend && npx nuxt build 2>&1 | grep -iE "error|roles|permission" | tail` — build OK, pas d'erreur de module manquant. +> Rappel LST-62 : `nuxt typecheck` n'est PAS un gate vert sur ce stack. Le vrai gate = build OK + aucun `Cannot find module` + auto-imports présents. Smoke navigateur (gestion des rôles) = PO. + +- [ ] **Step 7: Commit** + +```bash +git add -A -- frontend +git commit -m "feat(core) : add usePermissions composable and rbac roles admin front" +``` + +--- + +## Acceptance check (après toutes les phases) + +- [ ] **AC1** Permissions `module.resource.action` synchronisées via `app:sync-permissions` : la commande tourne sans erreur, `permission` contient les codes `core.*`. +- [ ] **AC2** Sidebar gated par permission ET par module actif : `/api/sidebar` masque les items selon `effectivePermissions` (et `disabledRoutes` selon module), ROLE_ADMIN voit tout. +- [ ] **AC3** `make test` vert (≈ 120 + nouveaux tests), `doctrine:schema:validate` mapping OK, `migrations:diff` = pas de changement RBAC (hors `messenger_messages`). +- [ ] `/api/me` expose `effectivePermissions`. +- [ ] `PermissionVoter` répond à `is_granted('core.*.*')` ; ROLE_ADMIN bypass. +- [ ] `app:seed-rbac` crée les rôles système `admin`/`user` (idempotent). +- [ ] Front : `usePermissions().can(code)` fonctionne ; gestion des rôles accessible aux porteurs de `core.roles.view`. +- [ ] Login/JWT/MCP inchangés (`204`/`200`/`200`) à chaque phase. +- [ ] `config/reference.php` jamais committé. + +## Notes pour les tickets suivants + +- **2.x (modules métier)** : chaque `*Module::permissions()` déclarera ses codes ; `app:sync-permissions` les upsertera ; les rôles métier (bureau/compta/…) seront seedés via une matrice étendue dans `RbacSeeder` quand les permissions existeront (Décision 4). +- **1.3 (Audit log)** : `core.audit_log.view` pourra être ajoutée à `CoreModule::permissions()` quand l'audit sera livré. +- **Migration `is_admin`** (optionnelle) : si le PO préfère le modèle Starseed, une phase ultérieure pourra remplacer le bypass `ROLE_ADMIN` par une colonne `is_admin` + data-migration (Décision 1). diff --git a/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md b/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md new file mode 100644 index 0000000..4593228 --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-61-audit-log.md @@ -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 + 20260619145109) : + +```php +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 +isMainRequest()) { + return; + } + + $this->requestId = Uuid::v4()->toRfc4122(); + } + + public function getRequestId(): ?string + { + return $this->requestId; + } +} +``` + +- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) : + +```php + 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 $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 $data + * + * @return array + */ + 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 +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> + */ + 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\\...\`, 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. diff --git a/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md b/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md new file mode 100644 index 0000000..506814d --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-lst-62-socle-front.md @@ -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//pages/` peuplé en 0.2 (le dossier `modules/` est créé vide pour le scan). +- **Zéro régression de navigation** : tous les liens actuels restent atteignables et correctement gardés (admin reste admin-only). +- **Auto-import Nuxt** : les composants/pages référencent les composables/stores **par nom** (`useApi()`, `useAuthStore()`), jamais par chemin → déplacer un fichier entre deux dossiers auto-scannés est transparent. Toujours le vérifier par un `typecheck` après déplacement. +- **Commits** : format `() : ` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic (message, body, trailers). +- **PHP** : `declare(strict_types=1);` en tête ; tests via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`. +- **TS** : strict, 4 espaces d'indentation, pas de `any`. +- **Pas de migration BDD** dans ce lot (aucune entité touchée). + +## Décisions de conception (actées avec le PO) + +1. **Gate de rôle minimal côté back** : les items/sections réservés (`/team-absences`, `/admin`) portent une clé `roles` dans `config/sidebar.php` ; `SidebarProvider` passe les rôles de l'utilisateur courant à `SidebarFilter` qui masque ce qui n'est pas autorisé. Ce n'est **pas** le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER. +2. **Items contextuels / feature-flag / user-flag hors `/api/sidebar`** : Kanban/Groupes/Archives (contexte `currentProjectId`), Documents (`shareEnabled`), Mail (+ badge non lus), Mes absences (`isEmployee`) restent rendus par le layout comme aujourd'hui. +3. **Délta cosmétique assumé** : la sidebar dynamique regroupe le Tableau de bord avec « Mes tâches / Projets / Suivi de temps » sous un même en-tête, et le bloc statique (contextuel/flag/Mes absences) s'insère après cette première section. Léger réordonnancement visuel, **à valider**, harmonisé en #60 (Finition Malio). Aucun lien perdu. + +## Vérification (pas de runner de tests JS dans ce projet) + +- **Back (Task 1)** : vraie TDD PHPUnit. +- **Front (Tasks 2-7)** : la verif = `typecheck` Nuxt (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 + 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [ + ['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertSame('/', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]); + } + + public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void + { + $sections = [ + ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [ + ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertSame([], $result['sections']); + self::assertSame(['/time-tracking'], $result['disabledRoutes']); + } + + public function testItemWithActiveModuleIsVisible(): void + { + $sections = [ + ['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [ + ['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'], + ]], + ]; + + $result = SidebarFilter::filter($sections, ['time_tracking'], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']); + self::assertSame([], $result['disabledRoutes']); + } + + public function testSectionWithRolesIsHiddenWhenRoleMissing(): void + { + $sections = [ + ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [ + ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertSame([], $result['sections']); + // Filtrage par rôle => PAS de disabledRoutes (réservé au filtrage par module). + self::assertSame([], $result['disabledRoutes']); + } + + public function testSectionWithRolesIsVisibleWhenRolePresent(): void + { + $sections = [ + ['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [ + ['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER', 'ROLE_ADMIN']); + + self::assertCount(1, $result['sections']); + self::assertSame('/admin', $result['sections'][0]['items'][0]['to']); + self::assertArrayNotHasKey('roles', $result['sections'][0]); + } + + public function testItemWithRolesIsHiddenWhenRoleMissing(): void + { + $sections = [ + ['label' => 'sidebar.hr.section', 'icon' => 'mdi:calendar', 'items' => [ + ['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group', 'roles' => ['ROLE_ADMIN']], + ['label' => 'sidebar.hr.x', 'to' => '/x', 'icon' => 'mdi:x'], + ]], + ]; + + $result = SidebarFilter::filter($sections, [], ['ROLE_USER']); + + self::assertCount(1, $result['sections']); + self::assertCount(1, $result['sections'][0]['items']); + self::assertSame('/x', $result['sections'][0]['items'][0]['to']); + self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]); + } +} +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: FAIL — `filter()` actuel n'accepte que 2 args / ne gère pas `roles` (erreur d'arité ou assertions rouges). + +- [ ] **Step 3: Étendre `SidebarFilter`** + +Remplace INTÉGRALEMENT `src/Shared/Domain/Sidebar/SidebarFilter.php` par : + +```php +, items: list}>}> $sections + * @param list $activeModuleIds + * @param list $activeRoles + * + * @return array{sections: list}>, disabledRoutes: list} + */ + public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array + { + $outSections = []; + $disabledRoutes = []; + + foreach ($sections as $section) { + // Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module). + if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) { + continue; + } + + $items = []; + foreach ($section['items'] as $item) { + // Gate de rôle au niveau item. + if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) { + continue; + } + + // Filtrage par module actif (pilote la redirection front via disabledRoutes). + $module = $item['module'] ?? null; + if (null !== $module && !in_array($module, $activeModuleIds, true)) { + $disabledRoutes[] = $item['to']; + + continue; + } + + $items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']]; + } + + if ([] !== $items) { + $outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items]; + } + } + + return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes]; + } + + /** + * @param list|null $required + * @param list $activeRoles + */ + private static function rolesSatisfied(?array $required, array $activeRoles): bool + { + if (null === $required || [] === $required) { + return true; + } + + foreach ($required as $role) { + if (in_array($role, $activeRoles, true)) { + return true; + } + } + + return false; + } +} +``` + +- [ ] **Step 4: Lancer le test unitaire, vérifier le vert** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php` +Expected: PASS (6 tests). + +- [ ] **Step 5: Injecter les rôles dans `SidebarProvider`** + +Remplace INTÉGRALEMENT `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` par : + +```php + $moduleClasses */ + $moduleClasses = require $this->projectDir.'/config/modules.php'; + + /** @var list, items: list}>}> $sidebar */ + $sidebar = require $this->projectDir.'/config/sidebar.php'; + + $user = $this->security->getUser(); + $roles = null !== $user ? $user->getRoles() : []; + + $filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles)); + + $dto = new SidebarResource(); + $dto->sections = $filtered['sections']; + $dto->disabledRoutes = $filtered['disabledRoutes']; + + return $dto; + } +} +``` + +- [ ] **Step 6: Compléter `config/sidebar.php`** + +Remplace INTÉGRALEMENT `config/sidebar.php` par (icônes alignées sur le layout actuel ; `/absences` retiré car gardé client-side via `isEmployee`) : + +```php +.). + */ +return [ + [ + 'label' => 'sidebar.general.section', + 'icon' => 'mdi:view-dashboard-outline', + 'items' => [ + ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], + ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'], + ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'], + ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'], + ], + ], + [ + 'label' => 'sidebar.admin.section', + 'icon' => 'mdi:cog-outline', + 'roles' => ['ROLE_ADMIN'], + 'items' => [ + ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], + ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'], + ], + ], +]; +``` + +- [ ] **Step 7: Renforcer le test fonctionnel sidebar (gate admin)** + +Remplace INTÉGRALEMENT `tests/Functional/Shared/SidebarEndpointTest.php` par : + +```php +request('GET', '/api/sidebar'); + + self::assertResponseStatusCodeSame(401); + } + + public function testSidebarReturnsSectionsForAuthenticatedUser(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + + self::assertResponseIsSuccessful(); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertArrayHasKey('sections', $data); + self::assertArrayHasKey('disabledRoutes', $data); + self::assertNotEmpty($data['sections']); + } + + public function testAdminSectionHiddenForNonAdmin(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // ROLE_USER + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + $data = json_decode($client->getResponse()->getContent(), true); + $labels = array_column($data['sections'], 'label'); + + self::assertNotContains('sidebar.admin.section', $labels); + } + + public function testAdminSectionVisibleForAdmin(): void + { + $client = self::createClient(); + $em = self::getContainer()->get('doctrine.orm.entity_manager'); + + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); // ROLE_ADMIN + $client->loginUser($user); + + $client->request('GET', '/api/sidebar'); + $data = json_decode($client->getResponse()->getContent(), true); + $labels = array_column($data['sections'], 'label'); + + self::assertContains('sidebar.admin.section', $labels); + } +} +``` + +- [ ] **Step 8: Lancer la suite complète, vérifier le vert** + +Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit` +Expected: PASS (les 110 tests précédents adaptés + nouveaux cas). Si `admin`/`alice` n'existent pas en base de test, vérifier les fixtures (`admin`/`admin`, `alice`/`alice` d'après CLAUDE.md). + +- [ ] **Step 9: php-cs-fixer + commit** + +Run: `make php-cs-fixer-allow-risky` +```bash +git add src/Shared/Domain/Sidebar/SidebarFilter.php src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php config/sidebar.php tests/Unit/Shared/Sidebar/SidebarFilterTest.php tests/Functional/Shared/SidebarEndpointTest.php +git commit -m "feat(sidebar) : add role gate to sidebar provider and global nav config" +``` + +--- + +### Task 2: Frontend — types + composables partagés (`useModules`, `useSidebar`) + +**Files:** +- Create: `frontend/shared/types/sidebar.ts` +- Create: `frontend/shared/composables/useModules.ts` +- Create: `frontend/shared/composables/useSidebar.ts` + +> Note : à cette étape `shared/` n'est pas encore dans `imports.dirs` (fait en Task 4). Ces fichiers sont créés ici mais référencés/auto-importés seulement après Task 4 ; le typecheck final de validation se fait donc en fin de Task 4. Cette task se termine sans verif runtime (pur ajout de fichiers). + +**Interfaces:** +- Produces : + - `useModules(): { activeModuleIds: Ref, loaded: Ref, loadModules(): Promise, isModuleActive(id: string): boolean, resetModules(): void }` + - `useSidebar(): { sections: Ref, disabledRoutes: Ref, loaded: Ref, loadSidebar(): Promise, isRouteDisabled(path: string): boolean, resetSidebar(): void }` + - `SidebarSection`, `SidebarItem` (types). +- Consumes : `useApi()` (auto-importé, déplacé en Task 3 — toujours appelé par nom). + +- [ ] **Step 1: Créer les types** + +`frontend/shared/types/sidebar.ts` : + +```ts +export type SidebarItem = { + label: string + to: string + icon: string +} + +export type SidebarSection = { + label: string + icon: string + items: SidebarItem[] +} +``` + +- [ ] **Step 2: Créer `useModules`** + +`frontend/shared/composables/useModules.ts` (état singleton au niveau module) : + +```ts +const activeModuleIds = ref([]) +const loaded = ref(false) + +export function useModules() { + async function loadModules(): Promise { + const api = useApi() + const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false }) + activeModuleIds.value = data.modules ?? [] + loaded.value = true + } + + function isModuleActive(id: string): boolean { + return activeModuleIds.value.includes(id) + } + + function resetModules(): void { + activeModuleIds.value = [] + loaded.value = false + } + + return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules } +} +``` + +> Vérifier la signature réelle de `useApi().get` (Task 3 / source actuelle) : `get(url, query?, options?)`. L'option `{ toast: false }` doit exister dans `ApiFetchOptions` ; si la clé diffère (ex. `toastSuccessKey`/`toast`), aligner sur la signature réelle de `useApi.ts`. Si aucune option « silencieux » n'existe, passer `{}`. + +- [ ] **Step 3: Créer `useSidebar`** + +`frontend/shared/composables/useSidebar.ts` : + +```ts +import type { SidebarSection } from '~/shared/types/sidebar' + +const sections = ref([]) +const disabledRoutes = ref([]) +const loaded = ref(false) + +export function useSidebar() { + async function loadSidebar(): Promise { + const api = useApi() + const data = await api.get<{ sections: SidebarSection[]; disabledRoutes: string[] }>( + '/sidebar', {}, { toast: false }, + ) + sections.value = data.sections ?? [] + disabledRoutes.value = data.disabledRoutes ?? [] + loaded.value = true + } + + function isRouteDisabled(path: string): boolean { + return disabledRoutes.value.some( + (disabled) => path === disabled || path.startsWith(disabled + '/'), + ) + } + + function resetSidebar(): void { + sections.value = [] + disabledRoutes.value = [] + loaded.value = false + } + + return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/shared/types/sidebar.ts frontend/shared/composables/useModules.ts frontend/shared/composables/useSidebar.ts +git commit -m "feat(front) : add shared useModules/useSidebar composables and sidebar types" +``` + +--- + +### Task 3: Frontend — déplacer `useApi` et les stores transverses vers `shared/` + +**Files:** +- Move: `frontend/composables/useApi.ts` → `frontend/shared/composables/useApi.ts` +- Move: `frontend/stores/auth.ts` → `frontend/shared/stores/auth.ts` +- Move: `frontend/stores/ui.ts` → `frontend/shared/stores/ui.ts` + +> `timer.ts` et `mail.ts` **restent** dans `frontend/stores/` (domaines métier non encore migrés en module). On ne déplace que les deux stores transverses (auth, ui) + `useApi`. La résolution effective (auto-import depuis `shared/`) est activée en Task 4 ; cette task fait les `git mv` et termine par un commit. Le typecheck de validation est en Task 4 (après config). + +- [ ] **Step 1: Déplacer les fichiers (git mv pour préserver l'historique)** + +```bash +cd /home/matthieu/dev_malio/Lesstime/frontend +mkdir -p shared/stores +git mv composables/useApi.ts shared/composables/useApi.ts +git mv stores/auth.ts shared/stores/auth.ts +git mv stores/ui.ts shared/stores/ui.ts +``` + +- [ ] **Step 2: Vérifier qu'aucun import par CHEMIN ne pointe vers les anciens emplacements** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "composables/useApi\|stores/auth\|stores/ui" --include=*.ts --include=*.vue . | grep -v node_modules | grep -v "shared/"` +Expected: aucun résultat (tout passe par auto-import). Si un import explicite existe (ex. `from '~/composables/useApi'`), le corriger en `from '~/shared/composables/useApi'` ou retirer l'import (auto-import). Noter chaque correction. + +> `layouts/default.vue` importe actuellement `useAppVersion` depuis `~/composables/useAppVersion` (NON déplacé) — ne pas y toucher ici. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(front) : move useApi and shared stores (auth, ui) to shared/" +``` + +--- + +### Task 4: Frontend — `nuxt.config.ts` (srcDir, dossiers `app/`, scan des layers, auto-imports) + +**Files:** +- Modify: `frontend/nuxt.config.ts` +- Create: `frontend/modules/.gitkeep` (dossier vide prêt pour le scan) +- Move: `frontend/layouts/` → `frontend/app/layouts/` (default.vue, auth.vue) +- Move: `frontend/middleware/` → `frontend/app/middleware/` (auth.global.ts, admin.ts, employee.ts) + +**Interfaces:** +- Produces : structure `app/{layouts,middleware}`, `modules/` scannable, `shared/*` auto-importé. + +- [ ] **Step 1: Déplacer layouts et middleware sous `app/`** + +```bash +cd /home/matthieu/dev_malio/Lesstime/frontend +mkdir -p app modules +git mv layouts app/layouts +git mv middleware app/middleware +touch modules/.gitkeep +git add modules/.gitkeep +``` + +- [ ] **Step 2: Réécrire `nuxt.config.ts`** + +Remplace INTÉGRALEMENT `frontend/nuxt.config.ts` par (conserve `vite`/`toast` existants — repris depuis la version actuelle) : + +```ts +import { existsSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' + +const modulesDir = resolve(__dirname, 'modules') +const moduleDirs = existsSync(modulesDir) + ? readdirSync(modulesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + : [] +const moduleLayers = moduleDirs.map((name) => `./modules/${name}`) +const moduleComposableDirs = moduleDirs + .map((name) => `modules/${name}/composables`) + .filter((path) => existsSync(resolve(__dirname, path))) +const moduleStoreDirs = moduleDirs + .map((name) => `modules/${name}/stores`) + .filter((path) => existsSync(resolve(__dirname, path))) + +export default defineNuxtConfig({ + compatibilityDate: '2025-07-15', + devtools: { enabled: false }, + ssr: false, + srcDir: '.', + css: ['~/assets/css/app.css', '~/assets/css/dark.css'], + app: { + baseURL: process.env.NODE_ENV === 'production' + ? (process.env.NUXT_PUBLIC_APP_BASE || '/') + : '/', + }, + extends: ['@malio/layer-ui', ...moduleLayers], + modules: [ + '@nuxtjs/tailwindcss', + '@pinia/nuxt', + 'nuxt-toast', + '@nuxtjs/i18n', + '@nuxt/icon', + ], + dir: { + layouts: 'app/layouts', + middleware: 'app/middleware', + }, + imports: { + dirs: [ + 'shared/composables', + 'shared/stores', + 'shared/utils', + 'composables', + 'stores', + 'utils', + ...moduleComposableDirs, + ...moduleStoreDirs, + ], + }, + pinia: { + storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'], + }, + runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE, + }, + }, + devServer: { + port: 3002, + }, + components: [ + { path: '~/components', pathPrefix: false }, + ], + // ⬇️ Reprendre VERBATIM les blocs `vite: {...}`, `toast: {...}`, `i18n: {...}`, + // `typescript: {...}`, `build: {...}` de l'ancien nuxt.config.ts (inchangés). + typescript: { strict: true }, + build: { transpile: ['@vuepic/vue-datepicker'] }, +}) +``` + +> ⚠️ Les blocs `vite`, `toast`, `i18n` de l'ancienne config ne sont pas réécrits ici : **les recopier à l'identique** depuis la version d'origine (récupérable via `git show HEAD~1:frontend/nuxt.config.ts` après les déplacements). Le `i18n.langDir: 'locales'` reste résolu depuis `i18n/`. + +- [ ] **Step 3: Typecheck complet (valide Tasks 2, 3 et 4)** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` +Expected: 0 erreur. Pièges probables : +- Store non trouvé → vérifier `pinia.storesDirs` inclut bien `shared/stores/**`. +- Composable non auto-importé → vérifier `imports.dirs` inclut `shared/composables`. +- `~/composables/useApi` cassé → un import explicite a survécu (corriger comme Task 3 Step 2). + +- [ ] **Step 4: Smoke test runtime — l'app boote et la nav existante fonctionne** + +Run: `cd /home/matthieu/dev_malio/Lesstime && make dev-nuxt` (ou rebuild SPA selon le workflow). Ouvrir l'app, se connecter (`alice`/`alice`), vérifier que la sidebar **statique actuelle** s'affiche encore et que la navigation marche (le layout n'est pas encore dynamisé — c'est normal). Aucun écran blanc / erreur console bloquante. +Expected: app fonctionnelle, identique à avant (les déplacements sont transparents). + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection" +``` + +--- + +### Task 5: Frontend — middlewares (`auth.global.ts` étendu + `modules.global.ts`) + +**Files:** +- Modify: `frontend/app/middleware/auth.global.ts` (charge sidebar + modules après login ; reset au logout) +- Create: `frontend/app/middleware/modules.global.ts` (redirige les routes désactivées) + +**Interfaces:** +- Consumes : `useAuthStore()`, `useSidebar()`, `useModules()` (auto-importés). + +- [ ] **Step 1: Étendre `auth.global.ts`** + +Remplace INTÉGRALEMENT `frontend/app/middleware/auth.global.ts` par : + +```ts +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + return navigateTo('/') + } + + const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar() + const { loaded: modulesLoaded, loadModules, resetModules } = useModules() + + if (auth.isAuthenticated) { + await Promise.all([ + sidebarLoaded.value ? Promise.resolve() : loadSidebar(), + modulesLoaded.value ? Promise.resolve() : loadModules(), + ]) + } else { + // Logout / session expirée : purge l'état partagé pour le prochain login. + resetSidebar() + resetModules() + } +}) +``` + +- [ ] **Step 2: Créer `modules.global.ts`** + +`frontend/app/middleware/modules.global.ts` : + +```ts +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + if (!auth.isAuthenticated) { + return + } + + const { loaded, loadSidebar, isRouteDisabled } = useSidebar() + if (!loaded.value) { + await loadSidebar() + } + + if (isRouteDisabled(to.path)) { + return navigateTo('/') + } +}) +``` + +> Ordre des middlewares globaux : Nuxt les exécute par ordre alphabétique de nom de fichier → `auth.global.ts` puis `modules.global.ts`. C'est l'ordre voulu (auth charge la sidebar avant que modules teste les routes désactivées). + +- [ ] **Step 3: Typecheck** + +Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` +Expected: 0 erreur. + +- [ ] **Step 4: Smoke test — chargement sidebar/modules + redirection** + +Avec le dev server : se connecter (`alice`), ouvrir l'onglet Réseau → confirmer un `GET /api/sidebar` et `GET /api/modules` après login. Vérifier la redirection : ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'` (module inactif) et un `'to' => '/demo-disabled'`, recharger, confirmer que `/demo-disabled` apparaît dans `disabledRoutes` (réponse `/api/sidebar`) et qu'y naviguer redirige vers `/`. **Puis retirer l'item de démo** (ne pas committer ce stub). +Expected: appels présents, redirection effective. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/app/middleware/auth.global.ts frontend/app/middleware/modules.global.ts +git commit -m "feat(front) : load sidebar/modules after login and redirect disabled routes" +``` + +--- + +### Task 6: Frontend — layout `default.vue` : sidebar dynamique + items conservés + +**Files:** +- Modify: `frontend/app/layouts/default.vue` + +**Interfaces:** +- Consumes : `useSidebar()` (sections dynamiques traduites), `useUiStore()`, `useAuthStore()`, `useI18n()`, + le reste de la logique existante (timer, mail, refData) conservée VERBATIM. + +> Stratégie : on remplace le bloc statique des items **globaux** (Tableau de bord, Mes tâches, Projets, Suivi de temps, Absences équipe, Administration) par un rendu **dynamique** issu de `useSidebar()`. On **conserve** les `SidebarLink` des items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail + badge) et user-flag (Mes absences) tels quels. Tout le ` +``` + +- [ ] **Step 2 : Vérifier le rendu** — `make dev-nuxt`, importer le composant dans une page de test ou attendre Task 16. Vérifier l'absence d'erreur console au build : `cd frontend && npx nuxi typecheck`. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/components/DirectoryContactBlock.vue +git commit -m "feat(directory) : add repeatable contact block component" +``` + +--- + +## Task 13 : Composant `DirectoryAddressBlock` + +**Files:** +- Create: `frontend/modules/directory/components/DirectoryAddressBlock.vue` + +**Interfaces:** +- Produces: bloc d'édition d'une adresse ; mêmes props/events que `DirectoryContactBlock` mais `modelValue: Address`. + +- [ ] **Step 1 : Écrire le composant** + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/components/DirectoryAddressBlock.vue +git commit -m "feat(directory) : add repeatable address block component" +``` + +--- + +## Task 14 : Composants documents de rapport (`ReportDocumentUpload`, `ReportDocumentList`) + +**Files:** +- Create: `frontend/modules/directory/components/ReportDocumentUpload.vue` +- Create: `frontend/modules/directory/components/ReportDocumentList.vue` + +**Interfaces:** +- Consumes: `useReportDocumentService` (Task 11), `ReportDocument` DTO. +- Produces: `ReportDocumentUpload` (prop `reportId: number`, event `uploaded`), `ReportDocumentList` (prop `documents: ReportDocument[]`, `isAdmin: boolean`, event `delete`). + +> S'inspirer de `frontend/modules/project-management/components/TaskDocumentUpload.vue` et `TaskDocumentList.vue` (les lire d'abord). Version simplifiée : upload simple + liste avec lien download + bouton suppression. Pas de SMB, pas de preview avancé (lien `getDownloadUrl` ouvert dans un onglet). + +- [ ] **Step 1 : `ReportDocumentUpload.vue`** + +```vue + + + +``` + +- [ ] **Step 2 : `ReportDocumentList.vue`** + +```vue + + + +``` + +- [ ] **Step 3 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 4 : Commit** + +```bash +git add frontend/modules/directory/components/ReportDocumentUpload.vue frontend/modules/directory/components/ReportDocumentList.vue +git commit -m "feat(directory) : add report document upload/list components" +``` + +--- + +## Task 15 : Onglet Rapport (`CommercialReportTab`) + +**Files:** +- Create: `frontend/modules/directory/components/CommercialReportTab.vue` + +**Interfaces:** +- Consumes: `useCommercialReportService` (Task 11), `ReportDocumentUpload`/`ReportDocumentList` (Task 14), composants Malio (`MalioInputText`, `MalioInputTextArea`, `MalioSelect`, `MalioDate`, `MalioButton`). +- Produces: onglet complet ; prop `owner: { client?: string, prospect?: string }`, `isAdmin: boolean`. Gère liste + formulaire d'ajout/édition + documents. + +- [ ] **Step 1 : Écrire le composant** + +```vue + + + +``` + +> Confirmer les props réelles de `MalioDate`, `MalioInputTextArea`, `MalioSelect` dans `COMPONENTS.md` (notamment `v-model` vs `model-value`/`@update:model-value`, et que `MalioSelect` accepte une valeur `string` — cf. CLAUDE.md). Ajuster si nécessaire. + +- [ ] **Step 2 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/components/CommercialReportTab.vue +git commit -m "feat(directory) : add commercial report tab (list, form, documents)" +``` + +--- + +## Task 16 : Fiche détail Client + +**Files:** +- Create: `frontend/modules/directory/pages/clients/[id].vue` + +**Interfaces:** +- Consumes: `useClientService`, `useContactService`, `useAddressService`, blocs Task 12/13, `CommercialReportTab` (Task 15), `MalioTabList`. +- Produces: page `/directory/clients/:id` ; onglets Contact/Adresse/Rapport ; owner = `{ client: '/api/clients/:id' }`. + +> `useClientService` n'a pas `getById` aujourd'hui (`services/clients.ts`). Ajouter une méthode `getById(id)` au service clients (calquée sur prospects.ts) dans cette tâche. + +- [ ] **Step 1 : Ajouter `getById` au service clients** + +Dans `frontend/modules/directory/services/clients.ts`, ajouter à l'intérieur de `useClientService` (et l'exposer dans le `return`) : + +```ts + async function getById(id: number): Promise { + return api.get(`/clients/${id}`) + } +``` + +- [ ] **Step 2 : Écrire la page** + +```vue + + + +``` + +> Décision UX explicite : la sauvegarde par bloc se fait au `@update:model-value` (à la frappe). Si ça génère trop d'appels, ajouter un debounce ou un bouton « Enregistrer » par bloc dans une itération ultérieure (hors périmètre de ce plan). + +- [ ] **Step 3 : Vérifier le routage et le rendu** + +Run: `make dev-nuxt` puis naviguer vers `/directory/clients/1`. +Expected: page chargée, 3 onglets visibles, ajout d'un contact persiste (vérifier via `GET /api/contacts?client=/api/clients/1`). + +- [ ] **Step 4 : Commit** + +```bash +git add frontend/modules/directory/pages/clients/ frontend/modules/directory/services/clients.ts +git commit -m "feat(directory) : add client detail page with contact/address/report tabs" +``` + +--- + +## Task 17 : Fiche détail Prospect + +**Files:** +- Create: `frontend/modules/directory/pages/prospects/[id].vue` + +**Interfaces:** +- Identique à Task 16 mais owner = `{ prospect: '/api/prospects/:id' }`, service `useProspectService().getById` (déjà présent). + +- [ ] **Step 1 : Écrire la page** + +Reprendre **intégralement** la page de Task 16 (`clients/[id].vue`) en remplaçant : +- `useClientService` → `useProspectService` ; `clientService.getById` → `prospectService.getById` ; type `Client` → `Prospect` (import `~/modules/directory/services/dto/prospect`). +- `const ownerIri = '/api/prospects/${id}'` ; `const owner = { prospect: ownerIri }`. +- Dans `emptyContact`/`emptyAddress` et les payloads : remplacer `client: ownerIri` par `prospect: ownerIri`. +- `getByOwner({ client: ownerIri })` → `getByOwner({ prospect: ownerIri })` (contacts et adresses). + +Code complet : + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le rendu** — `/directory/prospects/1`. Expected: 3 onglets, ajout de contact persiste. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/pages/prospects/ +git commit -m "feat(directory) : add prospect detail page with contact/address/report tabs" +``` + +--- + +## Task 18 : Navigation liste → fiche détail + +**Files:** +- Modify: `frontend/modules/directory/pages/directory.vue` + +**Interfaces:** +- Consumes: pages détail Task 16/17. +- Produces: clic sur une ligne client/prospect → navigation vers la fiche détail (au lieu d'ouvrir le drawer). Le drawer reste pour la création (`openCreateClient`/`openCreateProspect`). + +- [ ] **Step 1 : Modifier les handlers de clic** + +Dans `frontend/modules/directory/pages/directory.vue`, remplacer `openEditClient` et `openEditProspect` par une navigation : + +```ts +function openEditClient(item: Record) { + navigateTo(`/directory/clients/${(item as Client).id}`) +} + +function openEditProspect(item: Record) { + navigateTo(`/directory/prospects/${(item as Prospect).id}`) +} +``` + +Laisser `selectedClient`/`selectedProspect` et les drawers en place **uniquement** pour la création (`openCreateClient`/`openCreateProspect` inchangés). Retirer `selectedClient.value = item` des anciens handlers. + +- [ ] **Step 2 : Vérifier** + +Run: `make dev-nuxt` ; sur `/directory`, cliquer une ligne client → arrive sur `/directory/clients/:id` ; le bouton « Ajouter » ouvre toujours le drawer. + +- [ ] **Step 3 : Commit** + +```bash +git add frontend/modules/directory/pages/directory.vue +git commit -m "feat(directory) : open detail page on row click, keep drawer for creation" +``` + +--- + +## Task 19 : Traductions i18n + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +**Interfaces:** +- Produces: toutes les clés `directory.tabs.{contact,address,report}`, `directory.contacts.*`, `directory.addresses.*`, `directory.reports.*`, `directory.documents.*`, et les clés `common.*` utilisées (`back`, `loading`, `edit`, `delete`, `save`, `cancel`). + +- [ ] **Step 1 : Ajouter les clés sous `directory`** + +Dans `frontend/i18n/locales/fr.json`, étendre l'objet `directory` (fusionner avec l'existant `directory.title`/`directory.tabs.clients`/...) : + +```json +"directory": { + "tabs": { + "contact": "Contact", + "address": "Adresse", + "report": "Rapport" + }, + "contacts": { + "add": "Ajouter un contact", + "item": "Contact {n}", + "saved": "Contact enregistré.", + "deleted": "Contact supprimé.", + "fields": { + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone secondaire" + } + }, + "addresses": { + "add": "Ajouter une adresse", + "item": "Adresse {n}", + "saved": "Adresse enregistrée.", + "deleted": "Adresse supprimée.", + "fields": { + "label": "Libellé", + "street": "Rue", + "streetComplement": "Complément", + "postalCode": "Code postal", + "city": "Ville" + } + }, + "reports": { + "add": "Ajouter un compte-rendu", + "empty": "Aucun compte-rendu.", + "saved": "Compte-rendu enregistré.", + "deleted": "Compte-rendu supprimé.", + "fields": { + "subject": "Objet", + "type": "Type d'échange", + "occurredAt": "Date", + "body": "Compte-rendu" + }, + "types": { + "call": "Appel", + "meeting": "Rendez-vous", + "email": "Email", + "note": "Note" + } + }, + "documents": { + "add": "Joindre un document", + "uploading": "Envoi…", + "empty": "Aucun document.", + "deleted": "Document supprimé." + } +} +``` + +> ⚠️ Préserver les clés `directory` existantes (`title`, `tabs.clients`, `tabs.prospects`, `clients.*`, `prospects.*`). Fusionner, ne pas écraser. `tabs` reçoit `contact`/`address`/`report` **en plus** de `clients`/`prospects`. + +- [ ] **Step 2 : Ajouter les clés `common` manquantes** (si absentes — vérifier d'abord `grep '"common"' frontend/i18n/locales/fr.json`) + +```json +"common": { + "back": "Retour", + "loading": "Chargement…", + "edit": "Modifier", + "delete": "Supprimer", + "save": "Enregistrer", + "cancel": "Annuler" +} +``` + +- [ ] **Step 3 : Vérifier le JSON** + +Run: `docker exec -i php-lesstime-fpm node -e "require('./frontend/i18n/locales/fr.json')"` (ou `python3 -m json.tool frontend/i18n/locales/fr.json > /dev/null`). +Expected: pas d'erreur de parsing. + +- [ ] **Step 4 : Vérifier l'absence de clés manquantes en UI** — `make dev-nuxt`, parcourir les fiches : aucun libellé brut `directory.xxx` affiché. + +- [ ] **Step 5 : Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(directory) : add i18n keys for contacts, addresses, reports tabs" +``` + +--- + +## Task 20 : Vérification de bout en bout + +**Files:** aucun (vérification manuelle + suite de tests). + +- [ ] **Step 1 : Suite backend complète** + +Run: `make test` +Expected: vert. + +- [ ] **Step 2 : Style PHP** + +Run: `make php-cs-fixer-allow-risky` +Expected: fichiers conformes (commit si des corrections sont appliquées). + +- [ ] **Step 3 : Parcours fonctionnel** + +Avec `make dev-nuxt` et un compte `admin/admin` : +1. `/directory` → cliquer un client → fiche détail. +2. Onglet Contact : ajouter 2 contacts, recharger la page → les 2 sont présents. +3. Onglet Adresse : ajouter 1 adresse → présente après reload. +4. Onglet Rapport : créer un compte-rendu (type « Appel »), joindre un PDF → document listé et téléchargeable. +5. Sur un prospect avec contacts/adresses/rapports → « Convertir en client » → ouvrir le client créé → contacts/adresses/rapports présents sur le client, absents du prospect. + +- [ ] **Step 4 : Commit de clôture éventuel** (si php-cs-fixer a modifié des fichiers) + +```bash +git add -A +git commit -m "style(directory) : apply php-cs-fixer" +``` + +--- + +## Notes d'exécution + +- **Ordre des tâches :** 1 → 9 (backend) avant 10 → 19 (frontend) ; Task 5 doit précéder la migration (Task 6) car `CommercialReport` référence `ReportDocument`. +- **Points à confirmer en cours de route** (signalés inline) : style d'injection des services dans `services.yaml` (copier le pattern `TaskDocument*`), props exactes des composants Malio (`COMPONENTS.md`), chemin du type `UserData`, helper d'auth JWT des tests API. +- **Décision UX assumée :** sauvegarde par bloc à la frappe (Contact/Adresse). Debounce/bouton explicite = itération ultérieure si besoin. diff --git a/docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md b/docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md new file mode 100644 index 0000000..5f58cb6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-lst-56-modular-monolith-design.md @@ -0,0 +1,192 @@ +# LST-56 — Socle modular monolith DDD + pilote « Projets/Tâches » + +> Ticket Lesstime **#56** (1/5 — groupe « Refonte / Alignement Starseed »). +> Design validé le 2026-06-19. Référence vivante : repo **Starseed** (`.claude/rules/*.md` + implémentation réelle), et `Starseed/doc/architecture-modulaire-malio.md` (vision cible théorique — **non contraignante** là où elle diverge du code réel). + +## 1. Objectif & contraintes + +Poser dans Lesstime l'**infrastructure d'un modular monolith DDD** calquée sur Starseed, et **migrer un premier module pilote** (Projets/Tâches) de bout en bout comme preuve que la mécanique tient sur le cœur métier. + +Contraintes **non négociables** : + +- **Ne rien casser de l'existant.** Migration **strangler progressive** : le code legacy (`src/Entity/…`) et les modules (`src/Module/…`) coexistent ; l'application reste fonctionnelle et `make test` vert à **chaque** étape. +- **Prod = Docker, BDD peuplée** → uniquement des migrations **additives et nullable** (aucun `DROP`, aucun `NOT NULL` rétroactif, aucun déplacement de données). +- **Profondeur DDD : pragmatique**, alignée sur le **Starseed réel** (pas la doc théorique) : ORM attributs conservés dans les entités Domain, Repository = interface (Domain) + impl Doctrine (Infrastructure), Provider/Processor API Platform, contrats `Shared/Domain/Contract` pour le cross-module. **Pas de CQRS bus systématique, pas de multi-tenant.** + +### Décisions de cadrage (figées) + +| Sujet | Décision | +|-------|----------| +| Périmètre #56 | Socle complet + **1 module pilote** migré de bout en bout | +| Stratégie | **Strangler progressif** (legacy + modules en parallèle) | +| Profondeur DDD | **Pragmatique** (= Starseed réel) | +| Module pilote | **Projets/Tâches** (cœur métier) | +| Dépendances du pilote (User/Client/Notification) | Restent **legacy**, câblées via **contrats `Shared/Domain/Contract`** + `resolve_target_entities` | +| Infra d'audit Starseed | **Différée** → ticket Lesstime dédié (créé séparément) | +| Périmètre front #56 | **Câblage shell/shared/middlewares + migration du pilote en layer**, sans relooking (le relooking Malio reste #60) | +| Exposition API du pilote | **Garder les `#[ApiResource]` actuels** (étendre seulement les chemins de scan) — zéro régression API | +| Tâche → Notification | **Contrat `NotifierInterface`** (impl legacy crée la `Notification`) | +| Nom/ID du module | back `ProjectManagement` / front `project-management` / ID `project_management` | + +## 2. Garde-fous Starseed retenus pour #56 + +Repris : `declare(strict_types=1)`, `src/Module//{Domain,Application,Infrastructure}`, `Shared/Domain/Contract` + `resolve_target_entities` (zéro import inter-modules), `config/modules.php` + `config/sidebar.php`, endpoints `/api/modules` + `/api/sidebar` + `/api/version`, `TimestampableBlamableTrait` + subscriber, pagination obligatoire, `COMMENT ON COLUMN` (helper `ColumnCommentsCatalog`), front layers auto-détectés + `useSidebar`/`useModules` + `auth.global.ts`/`modules.global.ts`. + +Reportés (hors #56) : **infra d'audit** (`#[Auditable]`/`#[AuditIgnore]`, table `audit_log`, listener, resource) → ticket dédié. **RBAC fin** (`module.resource.action`) → #57 ; en #56 la sidebar filtre **par module actif** (au plus un gate `ROLE_ADMIN`). + +## 3. Backend — arborescence cible + +``` +src/Shared/ +├── Domain/ +│ ├── Contract/ UserInterface, UserResolverInterface, ClientInterface, NotifierInterface +│ ├── Event/ DomainEventInterface +│ └── Trait/ TimestampableBlamableTrait +├── Infrastructure/ +│ ├── Doctrine/ TimestampableBlamableSubscriber +│ ├── Database/ ColumnCommentsCatalog (helper COMMENT ON COLUMN + 4 colonnes std) +│ └── ApiPlatform/ +│ ├── Resource/ ModulesResource, SidebarResource +│ └── State/ ModulesProvider, SidebarProvider +│ +src/Module/ProjectManagement/ +├── ProjectManagementModule.php ID='project_management', LABEL='Projets', REQUIRED=false, permissions()=[] (stub, RBAC réel #57) +├── Domain/ +│ ├── Entity/ Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, +│ │ TaskPriority, TaskTag, TaskRecurrence, TaskDocument +│ └── Repository/ *RepositoryInterface (une interface par agrégat consommé) +├── Application/ RecurrenceCalculator/RecurrenceHandler + services task-centric déplacés +└── Infrastructure/ + ├── Doctrine/ Doctrine*Repository + Migrations/ (additif Timestampable) + ├── ApiPlatform/ State/Provider + State/Processor déplacés (TaskNumber, TaskCalendar, + │ TaskDocument*, SwitchProjectWorkflow, WorkflowDelete, ActiveTimeEntry resté legacy…) + └── Mcp/Tool/ MCP tools Project/, Task/, TaskMeta/, Workflow/ déplacés +``` + +`src/Entity/` conserve **intacts** : `User`, `Client`, `Notification`, `TimeEntry`, `AbsenceRequest`/`AbsencePolicy`/`AbsenceBalance`, `Mail*`, `Gitea*`/`BookStack*`/`Zimbra*`/`Share*Configuration`. Ces domaines seront modularisés dans des tickets ultérieurs. + +> **Note de découpage** : `TimeEntry` reste legacy en #56 (domaine Time tracking séparé). Le lien `Task ↔ TimeEntry` est porté côté `TimeEntry` (FK nullable vers la table `task`) ; aucune contrainte ne casse car la table `task` ne change pas de nom. + +## 4. Câblage des dépendances (zéro import inter-modules) + +1. Interfaces dans `src/Shared/Domain/Contract/` : + - `UserInterface` (id + identifiants nécessaires aux entités du module : assignee, collaborators, createdBy/updatedBy), + - `ClientInterface` (id + nom, pour `Project.client`), + - `UserResolverInterface` (résoudre un user par id, pour les State/MCP du module), + - `NotifierInterface` (créer une notification — impl legacy). +2. Les entités du module **type-hintent les interfaces**, jamais `App\Entity\*`. +3. `config/packages/doctrine.yaml → orm.resolve_target_entities` : + ```yaml + resolve_target_entities: + App\Shared\Domain\Contract\UserInterface: App\Entity\User + App\Shared\Domain\Contract\ClientInterface: App\Entity\Client + ``` +4. `App\Entity\User` `implements UserInterface`, `App\Entity\Client` `implements ClientInterface` (legacy modifié à minima, additif). +5. Notifications : `App\Module\ProjectManagement\…` appelle `NotifierInterface` ; impl `App\…\LegacyNotifier` (wrappe le `NotificationService` actuel). Le `TaskNotificationListener` est déplacé/adapté pour passer par le contrat. + +## 5. Config backend (toutes additives) + +- **`doctrine.yaml`** — ajouter un mapping module (garder `App → src/Entity`) : + ```yaml + mappings: + App: { type: attribute, is_bundle: false, dir: '%kernel.project_dir%/src/Entity', prefix: 'App\Entity', alias: App } + ProjectManagement: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity' + prefix: 'App\Module\ProjectManagement\Domain\Entity' + ``` + Les entités déplacées **gardent leur `#[ORM\Table(name: '…')]` actuel** (table inchangée → aucune donnée déplacée). `#[ORM\Entity(repositoryClass: DoctrineXxxRepository::class)]` mis à jour vers la nouvelle classe. +- **`doctrine_migrations.yaml`** — ajouter le namespace module (garder `DoctrineMigrations`) : + ```yaml + migrations_paths: + DoctrineMigrations: '%kernel.project_dir%/migrations' + 'App\Module\ProjectManagement\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/ProjectManagement/Infrastructure/Doctrine/Migrations' + ``` + > ⚠️ Doctrine Migrations trie par FQCN entre namespaces : le legacy `DoctrineMigrations` (setup initial) passe avant les migrations modulaires sur base vide. Sur la prod déjà migrée, seules les **nouvelles** migrations additives s'appliquent → pas d'impact d'ordre. +- **`api_platform.yaml`** — déclarer les chemins de mapping (entités + resources legacy **et** module) pour que les `#[ApiResource]` du pilote restent découverts : + ```yaml + mapping: + paths: + - '%kernel.project_dir%/src/Entity' + - '%kernel.project_dir%/src/ApiResource' + - '%kernel.project_dir%/src/Shared/Infrastructure/ApiPlatform/Resource' + - '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity' + ``` +- **`services.yaml`** — mettre à jour les FQCN explicites déplacés : `App\EventListener\TaskDocumentListener`, `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool` → nouveaux namespaces module. Le glob `App\: '../src/'` continue d'autowire les classes déplacées. + +## 6. Garde-fous portés dans #56 + +- **TimestampableBlamable** : trait `Shared/Domain/Trait/TimestampableBlamableTrait` (4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` — toutes **nullable**), rempli par `TimestampableBlamableSubscriber` (prePersist/preUpdate). Appliqué aux entités du pilote → **1 migration additive** par table concernée, avec `COMMENT ON COLUMN` via `ColumnCommentsCatalog::addStandardTimestampableBlamableComments()`. +- **Pagination** : conserver le standard API Platform actuel (les collections du pilote restent paginées comme aujourd'hui). +- **`COMMENT ON COLUMN`** : appliqué sur les colonnes ajoutées par #56 (pas de rétro-commentaire forcé sur le legacy). + +## 7. Endpoints modules / sidebar / version + +- `GET /api/modules` (public) — `ModulesResource` + `ModulesProvider` lisant `config/modules.php` (renvoie `{ modules: ["project_management", …] }`). +- `GET /api/sidebar` (auth) — `SidebarResource` + `SidebarProvider` lisant `config/sidebar.php` ; filtrage **par module actif** (item `module` absent de la liste active → masqué + route ajoutée à `disabledRoutes`) ; gate de section optionnel `ROLE_ADMIN`. Le filtrage par **permissions fines** est explicitement reporté à #57. +- `GET /api/version` — **déjà présent** (`AppVersion`) ; vérifier le format `{ version }`, ré-aligner si besoin (déplacement optionnel vers `Shared/`). +- `config/modules.php` : `return [ ProjectManagementModule::class ];` (Core viendra plus tard ; pas de module REQUIRED bloquant en #56). +- `config/sidebar.php` : sections « Projets » / « Mes tâches » avec `module => 'project_management'` ; les entrées des domaines encore legacy (Time tracking, Absences, Mail, Admin…) listées **sans** clé `module` (donc toujours visibles) pour ne rien masquer. + +## 8. Frontend — câblage + pilote en layer (sans relooking) + +``` +frontend/app/ +├── layouts/default.vue shell : sidebar (depuis /api/sidebar) + main +├── middleware/auth.global.ts protège routes, charge sidebar+modules après login +└── middleware/modules.global.ts redirige si route ∈ disabledRoutes +frontend/shared/ +├── composables/ useApi (déplacé), useSidebar, useModules, + existants réutilisés +├── stores/ auth, ui, timer (timer reste partagé : Time tracking encore legacy) +├── utils/ api.ts (extractHydraMembers/fetchAllHydra), … +└── types/ +frontend/modules/project-management/ +├── nuxt.config.ts defineNuxtConfig({}) +├── pages/ my-tasks.vue, projects/index.vue, projects/[id]/* (déplacés tels quels) +├── components/ task/*, project/* (déplacés) +├── services/ tasks.ts, projects.ts, task-*.ts, workflows.ts (déplacés) +└── stores/ (si spécifiques au domaine) +``` + +- **`nuxt.config.ts`** : auto-détection des layers `modules/*/` (scan `readdirSync` comme Starseed) ajoutés à `extends`, + dirs d'auto-import des composables/stores par layer. `extends: ['@malio/layer-ui']` conservé en tête. +- **`useSidebar`/`useModules`** : état singleton, `loadSidebar()`/`loadModules()` appelés dans `auth.global.ts`, `reset*()` au logout. +- **`modules.global.ts`** : `isRouteDisabled(to.path)` → `navigateTo('/')`. +- **Migration des pages** : déplacement **sans réécriture visuelle** ; les pages des autres domaines (time-tracking, absences, mail, admin, profile…) **restent dans `frontend/pages/`** (legacy) tant que leurs modules ne sont pas migrés. Nuxt fusionne les routes du shell + des layers → cohabitation transparente. + +> Point de vigilance front : vérifier que la cohabitation `frontend/pages/` (legacy) + `frontend/modules/*/pages/` (layer) ne crée pas de collision de routes ; `my-tasks`/`projects` sont déplacés **et retirés** de `frontend/pages/` pour éviter le doublon. + +## 9. Plan strangler (ordre d'exécution — app verte à chaque palier) + +1. **Shared/ + garde-fous** : trait, subscriber, `ColumnCommentsCatalog`. Neutre (rien ne les consomme encore). +2. **Endpoints modules/sidebar** + `config/modules.php` + `config/sidebar.php` (toutes entrées legacy sans `module` → rien masqué). Additif. +3. **Contrats `Shared/Domain/Contract`** + `resolve_target_entities` + `User`/`Client` `implements …Interface`. Neutre. +4. **Déplacement back du module** ProjectManagement (entités → Domain/Entity, repos → Infra/Doctrine + interfaces Domain, State, MCP) + mises à jour `doctrine.yaml`/`api_platform.yaml`/`doctrine_migrations.yaml`/`services.yaml`. **`make test` vert.** +5. **Migration additive Timestampable** sur les tables du pilote (+ `COMMENT ON COLUMN`). +6. **Front shell** : `app/` + `shared/` + middlewares + auto-détection `nuxt.config.ts`. App encore en pages plates. +7. **Déplacement front du pilote** vers `modules/project-management/` (pages/components/services), retrait des doublons de `frontend/pages/`. +8. **Vérification bout-en-bout** : commenter `ProjectManagementModule::class` dans `config/modules.php` → `/api/modules` ne le liste plus, `/api/sidebar` masque ses entrées + peuple `disabledRoutes`, le front redirige `/my-tasks`→`/`. Décommenter → tout revient. Documenter le test. + +## 10. Critères d'acceptation (repris du ticket, raffinés) + +- [ ] `src/Shared/` + `src/Module/ProjectManagement/{Domain,Application,Infrastructure}` en place. +- [ ] `/api/modules`, `/api/sidebar` fonctionnels ; `/api/version` aligné. +- [ ] Aucun import direct `App\Entity\User`/`Client` depuis le module (contrats + `resolve_target_entities`). +- [ ] Front : layers `frontend/modules/*/` auto-détectés ; `useSidebar`/`useModules` + `auth.global.ts`/`modules.global.ts` opérationnels ; pilote migré sans régression visuelle. +- [ ] Garde-fous : TimestampableBlamable (migration additive + `COMMENT ON COLUMN`) ; pagination conservée. **Audit explicitement hors périmètre** (ticket dédié). +- [ ] `make test` vert ; activation/désactivation du module validée de bout en bout. +- [ ] Aucune migration destructive ; prod déployable sans perte. + +## 11. Risques & points de vigilance + +- **Prod peuplée** : seules migrations additives nullable. `created_by`/`updated_by` non backfillés (historique) — conforme Starseed. +- **Changement de namespace des entités** : sans impact DB (Doctrine mappe par table). Vérifier qu'aucun code legacy ne référence en dur `App\Entity\Task` etc. → grep + remplacement (le pilote tire Task/Project, consommés par TimeEntry/Mail/BookStack links restés legacy : ces liens passeront par les contrats ou un type-hint relâché). +- **Collision de routes front** legacy vs layer (cf. §8). +- **MCP tools** (spécificité Lesstime) : déplacés sous `Module/*/Infrastructure/Mcp/` ; confirmer que `McpSchemaGeneratorPass` les redécouvre (scan `src/`). +- **`auto_mapping: true`** : valider que l'ajout d'un mapping explicite ne perturbe pas la résolution (sinon désactiver `auto_mapping` et lister explicitement). + +## 12. Suite + +- Ticket **audit** dédié à créer (infra `#[Auditable]` + `audit_log` + listener + resource), prérequis souple de #57. +- #57 RBAC fin (permissions `module.resource.action`, sidebar filtrée par permission). +- #58 Répertoire (Clients/Prospects), #59 Reporting, #60 Refonte front Malio. diff --git a/docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md b/docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md new file mode 100644 index 0000000..bfd5fd5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-migration-modular-monolith-roadmap.md @@ -0,0 +1,161 @@ +# Roadmap — Migration Lesstime → modular monolith DDD (archi Starseed) + +> Plan de migration **complet** validé le 2026-06-19. Référence architecture : repo **Starseed** +> (`.claude/rules/*.md` + implémentation réelle). Détail technique du socle : voir +> `2026-06-19-lst-56-modular-monolith-design.md`. + +## Principes directeurs + +- **Strangler progressif** : legacy (`src/Entity/…`) et modules (`src/Module/…`) coexistent ; l'app + reste fonctionnelle et `make test` vert à **chaque** merge. Aucune migration destructive (prod Docker, BDD peuplée → migrations **additives nullable** uniquement). +- **DDD pragmatique** (= Starseed réel) : ORM attrs dans l'entité Domain, Repository interface (Domain) + + impl Doctrine (Infra), Provider/Processor API Platform, contrats `Shared/Domain/Contract` pour le + cross-module. **Pas de CQRS bus, pas de multi-tenant.** +- **Tranches verticales** : chaque module de Phase 2 est livré **back + front (layer Malio) + MCP** + d'un coup → fonctionnel de bout en bout à son merge. L'ancienne idée d'un « ticket refonte front » + global est dissoute : chaque module arrive déjà en Malio ; un ticket de finition harmonise à la fin. +- **Ordre par dépendances** : socle → Core (identité/RBAC/audit) → modules métier → transverse/finition. +- **Zéro import inter-modules** : interfaces `Shared/Domain/Contract` + `resolve_target_entities`, + ou domain events / contrat `NotifierInterface`. + +## Garde-fous Starseed (appliqués à chaque entité migrée) + +`declare(strict_types=1)` · `TimestampableBlamableTrait` (4 colonnes nullable) + subscriber · +pagination obligatoire · `COMMENT ON COLUMN` (helper `ColumnCommentsCatalog`) · +`#[Auditable]`/`#[AuditIgnore]` (dès que 1.3 est livré) · front `Malio*` + `usePaginatedList` + +`useFormErrors` · RBAC `module.resource.action` (dès 1.2). + +--- + +## Phase 0 — Socle (fondations, ne touche aucun métier) + +### 0.1 · Socle back — infrastructure modulaire *(réécrit depuis #56)* +**Dépend de** : — +`src/Shared/Domain/Contract/` (UserInterface, UserResolverInterface, ClientInterface, NotifierInterface), +`Shared/Domain/Event/DomainEventInterface`, `Shared/Domain/Trait/TimestampableBlamableTrait`, +`Shared/Infrastructure/Doctrine/TimestampableBlamableSubscriber`, +`Shared/Infrastructure/Database/ColumnCommentsCatalog`, +`Shared/Infrastructure/ApiPlatform/{Resource,State}` (`ModulesResource`/`ModulesProvider`, +`SidebarResource`/`SidebarProvider`), `config/modules.php`, `config/sidebar.php`, `/api/version` aligné. +Config additive : mapping Doctrine module prêt, `migrations_paths` modulaire, `api_platform.mapping.paths`. +**AC** : `/api/modules` + `/api/sidebar` répondent ; app verte ; aucune migration destructive. + +### 0.2 · Socle front — shell + auto-détection des layers +**Dépend de** : 0.1 +`frontend/app/` (shell `layouts/default.vue`), `frontend/shared/` (`useApi` déplacé, `useSidebar`, +`useModules`, stores), middlewares `auth.global.ts` + `modules.global.ts`, auto-détection des layers +`modules/*/` dans `nuxt.config.ts`. **Aucune page métier déplacée** (app encore plate). +**AC** : sidebar dynamique depuis `/api/sidebar` ; routes désactivées redirigées ; app verte. + +--- + +## Phase 1 — Module Core (identité, sécurité, traçabilité — transverse) + +### 1.1 · Core — Identité & Notifications +**Dépend de** : 0.1, 0.2 +Migrer `User` + Auth/JWT dans `src/Module/Core/` (Domain/Entity, Repository interface + Doctrine impl, +`MeProvider`, password hasher), `User implements UserInterface`, `resolve_target_entities → Core\User`. +`Notification` exposée via `NotifierInterface`. `CoreModule.php` (**REQUIRED=true**). Front : layer +`modules/core/` (login, profile, admin users). +**AC** : login/JWT OK ; app verte ; aucun import direct `App\Entity\User` hors Core. + +### 1.2 · RBAC fin *(réécrit depuis #57)* +**Dépend de** : 1.1 +`Role`/`Permission`, `permissions()` par module, commande `app:sync-permissions`, `PermissionVoter`, +`SidebarProvider` filtrant **par permission** (en plus du module actif), seed RBAC. Front : gestion des +rôles + `usePermissions`. +**AC** : permissions `module.resource.action` ; sidebar gated par permission. + +### 1.3 · Audit log *(réécrit depuis #61)* +**Dépend de** : 1.1 +`#[Auditable]`/`#[AuditIgnore]` (`Shared/Domain/Attribute`), table `audit_log` (migration additive + +`COMMENT ON COLUMN`), `AuditListener`/`AuditLogWriter`/`RequestIdProvider`, `AuditLogResource` + +`/api/audit-logs` paginé/filtrable, page front + labels i18n `audit.entity.*`. +**AC** : CRUD des entités `#[Auditable]` tracé ; endpoint paginé ; aucune migration destructive. + +--- + +## Phase 2 — Modules métier (tranches verticales back + front + MCP, strangler) + +### 2.1 · Module TimeTracking *(premier module — rodage)* +**Dépend de** : 1.1 +Migrer `TimeEntry` → `src/Module/TimeTracking/` (Domain/Entity, repo, `ActiveTimeEntryProvider`, +`TimeEntryExportService`/controller, MCP TimeEntry tools), front layer `modules/time-tracking/` +(`time-tracking.vue`, components, services, store `timer`). Timestampable additif. **Rode toute la +mécanique modulaire à risque quasi nul.** +**AC** : time tracking fonctionnel en module ; activation/désactivation testée ; app verte. + +### 2.2 · Module ProjectManagement *(cœur métier — réécrit depuis #56 pilote)* +**Dépend de** : 2.1, 1.1 +`Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, +TaskDocument` → `src/Module/ProjectManagement/` (vertical back + MCP Task/Project/TaskMeta/Workflow + +front layer `modules/project-management/`). User/Client via contrats (Client encore legacy jusqu'à 2.4). +Notifications via `NotifierInterface`. `#[ApiResource]` conservés (étendre le scan). Timestampable additif. +**AC** : cœur en module sans régression API ; app verte. + +### 2.3 · Module Absence +**Dépend de** : 1.1 +`AbsenceRequest/AbsencePolicy/AbsenceBalance` + services (`AbsenceBalanceService`, `AbsenceDayCalculator`, +`PublicHolidayProvider`) + controllers (calendar, preview, justificatif) + MCP absence tools → +`src/Module/Absence/`, front layer `modules/absence/`. +**AC** : module absences complet ; app verte. + +### 2.4 · Module Directory — Clients + Prospects *(réécrit depuis #58)* +**Dépend de** : 1.1 (et après 2.2 qui référence Client via contrat) +`Client` → `src/Module/Directory/` + nouvelle entité `Prospect`. L'impl de `ClientInterface` migre du +legacy vers le module (`resolve_target_entities` mis à jour). Front répertoire (clients + prospects). +**AC** : Clients + Prospects en module ; contrats à jour ; app verte. + +### 2.5 · Module Mail +**Dépend de** : 1.1, 2.2 (TaskMailLink → Task) +`Mail*` + `TaskMailLink` + `MailSyncService` + controllers + settings → `src/Module/Mail/`, front layer. +Intègre le WIP `feat/mail-integration`. +**AC** : mail en module ; app verte. + +### 2.6 · Module Integration — Gitea / BookStack / Zimbra / Share +**Dépend de** : 1.1, 2.2 (liens Task) +Configs + services API (`GiteaApiService`, `BookStackApiService`, `CalDavService`, Share) + controllers + +liens → `src/Module/Integration/`, front (onglets admin + sections task). +**AC** : intégrations en module ; app verte. + +--- + +## Phase 3 — Transverse & finition + +### 3.1 · Module Reporting *(réécrit depuis #59)* +**Dépend de** : Phase 2 (consomme les modules) +Reporting natif transverse (agrège time tracking, tâches, absences) via contrats / API. Module +`src/Module/Reporting/` + front. +**AC** : rapports natifs ; aucune dépendance directe inter-modules. + +### 3.2 · Module Portail client +**Dépend de** : 1.1, 2.2, 2.4 +Portail client (accès restreint), module `src/Module/ClientPortal/` + front layer + RBAC dédié. +**AC** : portail fonctionnel ; gated RBAC. + +### 3.3 · Finition Malio + nettoyage legacy *(réécrit depuis #60)* +**Dépend de** : tout +Harmonisation visuelle Malio finale, **vidage de `src/Entity/` legacy résiduel**, suppression du mapping +Doctrine legacy + des pages plates `frontend/pages/` résiduelles, durcissement `resolve_target_entities`. +**AC** : `src/Entity` vide ; 100 % modulaire ; app verte ; aucune route/legacy orpheline. + +--- + +## Ordre d'exécution recommandé + +`0.1 → 0.2 → 1.1 → 1.2 → 1.3 → 2.1 → 2.2 → 2.3 → 2.4 → 2.5 → 2.6 → 3.1 → 3.2 → 3.3` + +Les tickets 1.2 et 1.3 peuvent se paralléliser après 1.1. Les modules 2.3 (Absence) et 2.4 (Directory) +peuvent se paralléliser après 2.2. Mail (2.5) et Integration (2.6) suivent 2.2. + +## Mapping avec les tickets Lesstime existants + +| Ancien | Devient | +|--------|---------| +| #56 (1/5 Aligner archi) | **0.1 Socle back** (le reste éclaté en 0.2 + 2.2) | +| #57 (2/5 RBAC) | **1.2 RBAC fin** | +| #58 (3/5 Répertoire) | **2.4 Directory** | +| #59 (4/5 Reporting) | **3.1 Reporting** | +| #60 (5/5 Front Malio) | **3.3 Finition Malio + nettoyage** (le front se fait par module) | +| #61 (Audit) | **1.3 Audit log** | +| *(créés)* | 0.2, 1.1, 2.1, 2.2, 2.3, 2.5, 2.6, 3.2 | diff --git a/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md b/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md new file mode 100644 index 0000000..16d8255 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md @@ -0,0 +1,203 @@ +# Répertoire — Contacts, Adresses & Rapports commerciaux + +**Date :** 2026-06-22 +**Module :** `Directory` (Lesstime) +**Statut :** Conception validée — prêt pour plan d'implémentation + +## Contexte & objectif + +Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement +minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`), +adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via +des drawers sur une page unique `/directory` à deux onglets, sans fiche détail. + +On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**, +inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet, +validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**. +Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date + +type d'échange + auteur) auquel on peut **joindre des documents**. + +Décisions cadrées avec l'utilisateur : +- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed). +- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer). +- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**. +- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports). +- Cible : **Lesstime** (Starseed sert uniquement de référence de design). + +## Approche retenue + +**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont +chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables +(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ». + +C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` + +CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent +avec le code existant. La conversion prospect→client se réduit à une **réaffectation de +FK** (pas de copie), ce qui préserve l'historique. + +Alternative écartée : entités dupliquées par propriétaire (`ClientContact` + +`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie. + +## Modèle de données (backend — `src/Module/Directory`) + +Toutes les nouvelles entités vivent dans le module `Directory` +(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`, +`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et +sont `#[Auditable]` comme `Client`/`Prospect`. + +### `Contact` (répétable) +| Champ | Type | Notes | +|-------|------|-------| +| id | int PK | | +| firstName | string? | | +| lastName | string? | | +| jobTitle | string? | fonction | +| email | string? | lowercase | +| phonePrimary | string? | | +| phoneSecondary | string? | | +| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE | +| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE | + +Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des +deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un +contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon +`isContactNamed` de Starseed). + +### `Address` (répétable) +| Champ | Type | Notes | +|-------|------|-------| +| id | int PK | | +| label | string? | libellé libre (« Siège », « Facturation »…) | +| street | string? | | +| streetComplement | string? | | +| postalCode | string? | | +| city | string? | | +| country | string | défaut `FR` | +| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK | + +### `CommercialReport` (compte-rendu, répétable) +| Champ | Type | Notes | +|-------|------|-------| +| id | int PK | | +| subject | string | objet du compte-rendu | +| body | text | le compte-rendu lui-même | +| occurredAt | date | date de l'échange | +| type | enum `ReportType` | `call` / `meeting` / `email` / `note` | +| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) | +| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) | +| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK | + +`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note. + +### Migration de l'adresse *inline* +Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées** +vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une +adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables +`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone` +restent sur `Client`/`Prospect` (identité principale). + +### Documents des comptes-rendus + +> **Correction post-exploration :** contrairement à une première hypothèse, `task_document` +> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000` +> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et +> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche. +> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui +> recouple `ProjectManagement` ↔ `Directory`. + +**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité +`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de +stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même +stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour +des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK +croisée `ProjectManagement` → `Directory`) au prix d'une duplication maîtrisée du processor +et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants +de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne +dépendent que du DTO document + de l'URL de download). + +Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK +`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`, +`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint +`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`), +`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`), +`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque), +`GetCollection` filtrable par `commercialReport`. + +## API Platform + +Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec : +- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`. +- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection + d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`). +- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module). +- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`, + `commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque + `author` (id + username) et `documents`. + +Permissions RBAC ajoutées au `Module::permissions()` : +`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par +`directory.clients.*` / `directory.prospects.*` existants.) + +## Conversion prospect → client + +`ConvertProspectProcessor` +(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`) +est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et +`CommercialReport` du prospect → set `client = ` et `prospect = null`. +Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent +automatiquement (rattachés au `CommercialReport`, pas au prospect). + +## Frontend (Nuxt — `frontend/modules/directory`) + +### Liste & navigation +- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**. +- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer. +- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide** + (champs principaux : name/email/phone, + company/status/source/notes pour le prospect). + +### Fiches détail +`pages/clients/[id].vue` et `pages/prospects/[id].vue` : +- En-tête : retour + titre + actions (archiver/supprimer selon droits). +- Bloc principal (identité : name/email/phone…), éditable en place. +- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** : + - **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc + POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`. + - **Adresse** : `DirectoryAddressBlock` répétable, même mécanique. + - **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire + d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` / + `ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques). + +Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect), +paramétrés par l'IRI du propriétaire (`client` ou `prospect`). + +### Services & DTO +Nouveaux services `services/contacts.ts`, `services/addresses.ts`, +`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés +(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service +existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`. + +## i18n + +Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse, +Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note), +toasts de succès (créé/mis à jour/supprimé) et messages de validation. + +## Tests (PHPUnit) + +- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin). +- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le + client (`prospect = null`), idempotence. +- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`. +- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté. +- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect + ayant une adresse non vide). + +> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants** +> (relations, statuts, présence), pas des **counts absolus**. + +## Hors périmètre (YAGNI) + +- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit). +- Pas de dashboard/statistiques commerciales chiffrées. +- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage). +- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre. diff --git a/frontend/layouts/auth.vue b/frontend/app/layouts/auth.vue similarity index 100% rename from frontend/layouts/auth.vue rename to frontend/app/layouts/auth.vue diff --git a/frontend/layouts/default.vue b/frontend/app/layouts/default.vue similarity index 55% rename from frontend/layouts/default.vue rename to frontend/app/layouts/default.vue index 70ec12b..a7c1729 100644 --- a/frontend/layouts/default.vue +++ b/frontend/app/layouts/default.vue @@ -38,131 +38,47 @@