diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 0baaedd..fe3adb4 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -22,6 +22,10 @@ doctrine: auto_mapping: true resolve_target_entities: App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User + App\Shared\Domain\Contract\ProjectInterface: App\Entity\Project + App\Shared\Domain\Contract\TaskInterface: App\Entity\Task + App\Shared\Domain\Contract\TaskTagInterface: App\Entity\TaskTag + App\Shared\Domain\Contract\ClientInterface: App\Entity\Client mappings: App: type: attribute diff --git a/docs/superpowers/plans/2026-06-20-lst-65-module-projectmanagement.md b/docs/superpowers/plans/2026-06-20-lst-65-module-projectmanagement.md new file mode 100644 index 0000000..b9c5504 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-lst-65-module-projectmanagement.md @@ -0,0 +1,82 @@ +# LST-65 (2.2) — Module ProjectManagement : plan de migration + +> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`. +> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes** +> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé). + +**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2). +**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear` +**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts). +**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules. + +## Périmètre (10 entités + écosystème) +Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument. +Enums : StatusCategory, RecurrenceType. +Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests. + +## Décisions d'architecture (figées) +1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale : + - `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string` + - `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string` + - `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string` + - `ClientInterface` : `getId(): ?int`, `getName(): ?string` + - PAS de WorkflowInterface (Workflow est intra-module PM). +2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`. +3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X` → `App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6. +4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`). +5. **Services CalDavService + RecurrenceCalculator** → `Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6). +6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM. +7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT). +8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive. +9. **resolve_target_entities** final : + ``` + UserInterface -> App\Module\Core\Domain\Entity\User (existant) + ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project + TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task + TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag + ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4) + ``` + +--- + +## Tranche 1 — Découplage EN PLACE (entités non déplacées) +But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural. + +1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus). +2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.) +3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter). +4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task. +5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade). +6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`. + +## Tranche 2 — Move mécanique vers le module +But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement. + +1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`. +2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté). +3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9). +4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`). +5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.) +6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.) +7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif). +8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`). +9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`. +10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`. + +## Tranche 3 — Timestampable additif (Task + Project) +1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`. +2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts. +3. Champs hors groupes API existants (le trait porte ses propres groupes). +4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`. + +## Tranche 4 — Front layer project-management +1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`. +2. Réécrire imports explicites `~/services/` + `~/services/dto/` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine. +3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé. +4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`. + +## Critères d'acceptation (ticket) +- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés). +- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré. +- [ ] `make test` vert, aucune migration destructive. +- [ ] Toggle module project-management (sidebar + routes) prouvé. diff --git a/src/Entity/Client.php b/src/Entity/Client.php index dd782b2..48cbee1 100644 --- a/src/Entity/Client.php +++ b/src/Entity/Client.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Repository\ClientRepository; +use App\Shared\Domain\Contract\ClientInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -29,7 +30,7 @@ use Symfony\Component\Serializer\Attribute\Groups; order: ['name' => 'ASC'], )] #[ORM\Entity(repositoryClass: ClientRepository::class)] -class Client +class Client implements ClientInterface { #[ORM\Id] #[ORM\GeneratedValue] diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 6b5267b..32acbd7 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -15,6 +15,8 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\ApiResource\SwitchWorkflowOutput; use App\Repository\ProjectRepository; +use App\Shared\Domain\Contract\ClientInterface; +use App\Shared\Domain\Contract\ProjectInterface; use App\State\SwitchProjectWorkflowProcessor; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -54,7 +56,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiFilter(BooleanFilter::class, properties: ['archived'])] #[ORM\Entity(repositoryClass: ProjectRepository::class)] #[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')] -class Project +class Project implements ProjectInterface { #[ORM\Id] #[ORM\GeneratedValue] @@ -80,10 +82,10 @@ class Project #[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])] private ?string $color = '#222783'; - #[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'projects')] + #[ORM\ManyToOne(targetEntity: ClientInterface::class, inversedBy: 'projects')] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[Groups(['project:read', 'project:write'])] - private ?Client $client = null; + private ?ClientInterface $client = null; #[ORM\ManyToOne(targetEntity: Workflow::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')] @@ -173,12 +175,12 @@ class Project return $this; } - public function getClient(): ?Client + public function getClient(): ?ClientInterface { return $this->client; } - public function setClient(?Client $client): static + public function setClient(?ClientInterface $client): static { $this->client = $client; diff --git a/src/Entity/Task.php b/src/Entity/Task.php index 4346a37..6977231 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Repository\TaskRepository; +use App\Shared\Domain\Contract\TaskInterface; use App\Shared\Domain\Contract\UserInterface; use App\State\TaskCalendarProcessor; use App\State\TaskNumberProcessor; @@ -46,7 +47,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; #[ORM\Entity(repositoryClass: TaskRepository::class)] #[ORM\Table(name: 'task')] #[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])] -class Task +class Task implements TaskInterface { #[ORM\Id] #[ORM\GeneratedValue] diff --git a/src/Entity/TaskTag.php b/src/Entity/TaskTag.php index 4f3a864..c351e7f 100644 --- a/src/Entity/TaskTag.php +++ b/src/Entity/TaskTag.php @@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use App\Repository\TaskTagRepository; +use App\Shared\Domain\Contract\TaskTagInterface; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups; )] #[ORM\Entity(repositoryClass: TaskTagRepository::class)] #[ORM\Table(name: 'task_type')] -class TaskTag +class TaskTag implements TaskTagInterface { #[ORM\Id] #[ORM\GeneratedValue] diff --git a/src/Module/TimeTracking/Domain/Entity/TimeEntry.php b/src/Module/TimeTracking/Domain/Entity/TimeEntry.php index 4d8d1bb..1ed090d 100644 --- a/src/Module/TimeTracking/Domain/Entity/TimeEntry.php +++ b/src/Module/TimeTracking/Domain/Entity/TimeEntry.php @@ -13,12 +13,12 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; -use App\Entity\Project; -use App\Entity\Task; -use App\Entity\TaskTag; use App\Module\TimeTracking\Infrastructure\ApiPlatform\State\ActiveTimeEntryProvider; use App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository; use App\Shared\Domain\Contract\BlamableInterface; +use App\Shared\Domain\Contract\ProjectInterface; +use App\Shared\Domain\Contract\TaskInterface; +use App\Shared\Domain\Contract\TaskTagInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\UserInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; @@ -91,18 +91,18 @@ class TimeEntry implements TimestampableInterface, BlamableInterface #[Groups(['time_entry:read', 'time_entry:write'])] private ?UserInterface $user = null; - #[ORM\ManyToOne(targetEntity: Project::class)] + #[ORM\ManyToOne(targetEntity: ProjectInterface::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[Groups(['time_entry:read', 'time_entry:write'])] - private ?Project $project = null; + private ?ProjectInterface $project = null; - #[ORM\ManyToOne(targetEntity: Task::class)] + #[ORM\ManyToOne(targetEntity: TaskInterface::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] #[Groups(['time_entry:read', 'time_entry:write'])] - private ?Task $task = null; + private ?TaskInterface $task = null; - /** @var Collection */ - #[ORM\ManyToMany(targetEntity: TaskTag::class)] + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: TaskTagInterface::class)] #[ORM\JoinTable( name: 'time_entry_task_type', joinColumns: [new ORM\JoinColumn(name: 'time_entry_id', referencedColumnName: 'id')], @@ -181,37 +181,37 @@ class TimeEntry implements TimestampableInterface, BlamableInterface return $this; } - public function getProject(): ?Project + public function getProject(): ?ProjectInterface { return $this->project; } - public function setProject(?Project $project): static + public function setProject(?ProjectInterface $project): static { $this->project = $project; return $this; } - public function getTask(): ?Task + public function getTask(): ?TaskInterface { return $this->task; } - public function setTask(?Task $task): static + public function setTask(?TaskInterface $task): static { $this->task = $task; return $this; } - /** @return Collection */ + /** @return Collection */ public function getTags(): Collection { return $this->tags; } - public function addTag(TaskTag $tag): static + public function addTag(TaskTagInterface $tag): static { if (!$this->tags->contains($tag)) { $this->tags->add($tag); @@ -220,7 +220,7 @@ class TimeEntry implements TimestampableInterface, BlamableInterface return $this; } - public function removeTag(TaskTag $tag): static + public function removeTag(TaskTagInterface $tag): static { $this->tags->removeElement($tag); diff --git a/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php b/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php index ebaa188..e9c2c16 100644 --- a/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php +++ b/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Module\TimeTracking\Domain\Repository; -use App\Entity\Project; use App\Module\TimeTracking\Domain\Entity\TimeEntry; +use App\Shared\Domain\Contract\ProjectInterface; use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; use Doctrine\ORM\QueryBuilder; @@ -19,9 +19,9 @@ interface TimeEntryRepositoryInterface public function findActiveByUser(UserInterface $user): ?TimeEntry; /** - * @param null|UserInterface[] $users - * @param null|Project[] $projects - * @param null|int[] $tagIds + * @param null|UserInterface[] $users + * @param null|ProjectInterface[] $projects + * @param null|int[] $tagIds * * @return TimeEntry[] */ diff --git a/src/Module/TimeTracking/Infrastructure/Doctrine/DoctrineTimeEntryRepository.php b/src/Module/TimeTracking/Infrastructure/Doctrine/DoctrineTimeEntryRepository.php index b824f22..399771b 100644 --- a/src/Module/TimeTracking/Infrastructure/Doctrine/DoctrineTimeEntryRepository.php +++ b/src/Module/TimeTracking/Infrastructure/Doctrine/DoctrineTimeEntryRepository.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Module\TimeTracking\Infrastructure\Doctrine; -use App\Entity\Project; use App\Module\TimeTracking\Domain\Entity\TimeEntry; use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface; +use App\Shared\Domain\Contract\ProjectInterface; use App\Shared\Domain\Contract\UserInterface; use DateTimeImmutable; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -36,9 +36,9 @@ class DoctrineTimeEntryRepository extends ServiceEntityRepository implements Tim } /** - * @param null|UserInterface[] $users - * @param null|Project[] $projects - * @param null|int[] $tagIds + * @param null|UserInterface[] $users + * @param null|ProjectInterface[] $projects + * @param null|int[] $tagIds * * @return TimeEntry[] */ diff --git a/src/Shared/Domain/Contract/ClientInterface.php b/src/Shared/Domain/Contract/ClientInterface.php new file mode 100644 index 0000000..afe7b3a --- /dev/null +++ b/src/Shared/Domain/Contract/ClientInterface.php @@ -0,0 +1,15 @@ +