Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s

## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-06-23 13:50:42 +00:00
parent d0a49322e1
commit 8313c759c6
622 changed files with 24802 additions and 2864 deletions
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Module\Mail\Application\Service;
use App\Module\Mail\Application\Dto\MailSyncReport;
use App\Module\Mail\Domain\Entity\MailFolder;
use App\Module\Mail\Domain\Entity\MailMessage;
use App\Module\Mail\Domain\Exception\MailProviderException;
use App\Module\Mail\Domain\Provider\MailProviderInterface;
use App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface;
use App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface;
use App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\Lock\LockFactory;
use Throwable;
final class MailSyncService
{
private const int FLAGS_RESYNC_LIMIT = 200;
private const string LOCK_NAME = 'mail.sync';
private const float LOCK_TTL = 600.0;
public function __construct(
private readonly MailProviderInterface $provider,
private readonly MailConfigurationRepositoryInterface $configRepository,
private readonly MailFolderRepositoryInterface $folderRepository,
private readonly MailMessageRepositoryInterface $messageRepository,
private readonly EntityManagerInterface $entityManager,
private readonly LockFactory $lockFactory,
private readonly LoggerInterface $logger,
private readonly ManagerRegistry $managerRegistry,
) {}
public function syncAll(): MailSyncReport
{
$startedAt = new DateTimeImmutable();
$config = $this->configRepository->findSingleton();
if (null === $config || !$config->isEnabled()) {
$this->logger->info('mail.sync skipped: mail config is disabled or missing');
return $this->emptyReport($startedAt, []);
}
$lock = $this->lockFactory->createLock(self::LOCK_NAME, ttl: self::LOCK_TTL, autoRelease: true);
if (!$lock->acquire()) {
$this->logger->info('mail.sync skipped: another sync in progress');
return $this->emptyReport($startedAt, ['lock_not_acquired']);
}
try {
return $this->doSyncAll($startedAt);
} finally {
$this->provider->closeConnection();
$lock->release();
}
}
public function syncFolderStructure(): void
{
try {
$remoteFolders = $this->provider->listFolders();
} catch (MailProviderException $e) {
$this->logger->error('syncFolderStructure: listFolders failed: '.$e->getMessage());
return;
}
$remotePathSet = [];
foreach ($remoteFolders as $dto) {
$remotePathSet[$dto->path] = true;
$folder = $this->folderRepository->findByPath($dto->path);
if (null === $folder) {
$folder = new MailFolder();
$folder->setPath($dto->path);
}
$folder->setDisplayName($dto->displayName);
$folder->setParentPath($dto->parentPath);
$folder->setUnreadCount($dto->unreadCount);
$folder->setTotalCount($dto->totalCount);
$this->entityManager->persist($folder);
}
$this->entityManager->flush();
$allDbFolders = $this->folderRepository->findAllOrderedByPath();
foreach ($allDbFolders as $dbFolder) {
if (!isset($remotePathSet[$dbFolder->getPath()])) {
$this->logger->warning(sprintf(
'syncFolderStructure: folder "%s" no longer exists on server — keeping in DB for safety',
$dbFolder->getPath()
));
}
}
}
public function syncFolder(MailFolder $folder): MailSyncReport
{
$startedAt = new DateTimeImmutable();
$createdCount = 0;
$updatedCount = 0;
$deletedCount = 0;
$errors = [];
$remoteHeaders = null;
try {
$lastUid = $this->messageRepository->findMaxUidInFolder($folder);
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
foreach ($remoteHeaders as $header) {
if ($header->uid <= $lastUid) {
continue;
}
$existing = $this->messageRepository->findByFolderAndUid($folder, $header->uid);
if (null !== $existing) {
continue;
}
$message = new MailMessage();
$message->setFolder($folder);
$message->setUid($header->uid);
$message->setMessageId($header->messageId);
$message->setSubject($header->subject);
$message->setFromAddress($header->fromAddress);
$message->setFromName($header->fromName);
$message->setToAddresses($header->toAddresses);
$message->setCcAddresses($header->ccAddresses);
$message->setSentAt($header->sentAt);
$message->setIsRead($header->isRead);
$message->setIsFlagged($header->isFlagged);
$message->setHasAttachments($header->hasAttachments);
$message->setSnippet($header->snippet);
$message->setSyncedAt(new DateTimeImmutable());
$this->entityManager->persist($message);
++$createdCount;
}
$this->entityManager->flush();
} catch (MailProviderException $e) {
$this->logger->error(sprintf('syncFolder[%s] listMessages failed: %s', $folder->getPath(), $e->getMessage()));
$errors[] = $e->getMessage();
} catch (Throwable $e) {
$this->logger->error(sprintf('syncFolder[%s] unexpected error: %s', $folder->getPath(), $e->getMessage()));
$errors[] = $e->getMessage();
}
try {
$recentMessages = $this->messageRepository->findLastNByFolder($folder, self::FLAGS_RESYNC_LIMIT);
if (null !== $recentMessages && [] !== $recentMessages) {
$remoteByUid = [];
if (null !== $remoteHeaders) {
foreach ($remoteHeaders as $h) {
$remoteByUid[$h->uid] = $h;
}
}
foreach ($recentMessages as $dbMessage) {
$remote = $remoteByUid[$dbMessage->getUid()] ?? null;
if (null === $remote) {
continue;
}
$changed = false;
if ($dbMessage->isRead() !== $remote->isRead) {
$dbMessage->setIsRead($remote->isRead);
$changed = true;
}
if ($dbMessage->isFlagged() !== $remote->isFlagged) {
$dbMessage->setIsFlagged($remote->isFlagged);
$changed = true;
}
if ($changed) {
++$updatedCount;
}
}
$this->entityManager->flush();
}
} catch (Throwable $e) {
$this->logger->warning(sprintf('syncFolder[%s] flag resync failed: %s', $folder->getPath(), $e->getMessage()));
}
try {
$dbUids = $this->messageRepository->findAllUidsByFolder($folder);
if ([] !== $dbUids) {
if (null === $remoteHeaders) {
$remoteHeaders = $this->provider->listMessages($folder->getPath(), limit: 5000, offset: 0);
}
$remoteUidSet = [];
foreach ($remoteHeaders as $h) {
$remoteUidSet[$h->uid] = true;
}
$toDelete = array_filter($dbUids, static fn (int $uid) => !isset($remoteUidSet[$uid]));
$toDeleteCount = count($toDelete);
$dbTotal = count($dbUids);
if ($toDeleteCount > (int) ($dbTotal * 0.5)) {
$warningMsg = sprintf(
'syncFolder[%s] suppression guard triggered: %d/%d would be deleted (>50%%) — aborting deletions',
$folder->getPath(),
$toDeleteCount,
$dbTotal
);
$this->logger->warning($warningMsg);
$errors[] = $warningMsg;
} else {
foreach ($toDelete as $uid) {
$dbMessage = $this->messageRepository->findByFolderAndUid($folder, $uid);
if (null !== $dbMessage) {
$this->entityManager->remove($dbMessage);
++$deletedCount;
}
}
$this->entityManager->flush();
}
}
} catch (MailProviderException $e) {
$this->logger->error(sprintf('syncFolder[%s] deletion detection failed: %s', $folder->getPath(), $e->getMessage()));
$errors[] = $e->getMessage();
}
$finishedAt = new DateTimeImmutable();
return new MailSyncReport(
createdCount: $createdCount,
updatedCount: $updatedCount,
deletedCount: $deletedCount,
foldersScanned: 1,
errors: $errors,
durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
startedAt: $startedAt,
finishedAt: $finishedAt,
);
}
private function doSyncAll(DateTimeImmutable $startedAt): MailSyncReport
{
$this->syncFolderStructure();
$totalCreated = 0;
$totalUpdated = 0;
$totalDeleted = 0;
$totalFolders = 0;
$allErrors = [];
$folders = $this->folderRepository->findAllOrderedByPath();
foreach ($folders as $folder) {
try {
$report = $this->syncFolder($folder);
$totalCreated += $report->createdCount;
$totalUpdated += $report->updatedCount;
$totalDeleted += $report->deletedCount;
++$totalFolders;
$allErrors = array_merge($allErrors, $report->errors);
// A folder error can leave the reused IMAP connection in a bad
// selection state ("must be in SELECTED state", "empty response").
// Drop it so the next folder reconnects on a clean session.
if ([] !== $report->errors) {
$this->provider->closeConnection();
}
} catch (Throwable $e) {
$this->logger->error(sprintf('doSyncAll: syncFolder[%s] threw: %s', $folder->getPath(), $e->getMessage()));
$allErrors[] = $e->getMessage();
$this->provider->closeConnection();
}
// A failed flush closes the Doctrine EntityManager; without a reset
// every subsequent folder would fail with "EntityManager is closed".
// Reset it via the registry and stop the run cleanly — the next cron
// cycle resumes incrementally from where we left off.
if (!$this->entityManager->isOpen()) {
$this->logger->error('doSyncAll: EntityManager was closed mid-sync, resetting and aborting this run');
$this->managerRegistry->resetManager();
$allErrors[] = 'EntityManager closed mid-sync — run aborted, will resume next cycle';
break;
}
}
$finishedAt = new DateTimeImmutable();
$this->logger->info(sprintf(
'mail.sync done: %d created, %d updated, %d deleted, %d folders, %d errors, %.1fs',
$totalCreated,
$totalUpdated,
$totalDeleted,
$totalFolders,
count($allErrors),
(float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp())
));
return new MailSyncReport(
createdCount: $totalCreated,
updatedCount: $totalUpdated,
deletedCount: $totalDeleted,
foldersScanned: $totalFolders,
errors: $allErrors,
durationSeconds: (float) ($finishedAt->getTimestamp() - $startedAt->getTimestamp()),
startedAt: $startedAt,
finishedAt: $finishedAt,
);
}
/**
* @param list<string> $errors
*/
private function emptyReport(DateTimeImmutable $startedAt, array $errors): MailSyncReport
{
$now = new DateTimeImmutable();
return new MailSyncReport(
createdCount: 0,
updatedCount: 0,
deletedCount: 0,
foldersScanned: 0,
errors: $errors,
durationSeconds: 0.0,
startedAt: $startedAt,
finishedAt: $now,
);
}
}