Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user