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
-93
View File
@@ -1,93 +0,0 @@
export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie'
export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'
export type HalfDay = 'matin' | 'apres_midi'
export type AbsenceUserRef = {
'@id'?: string
id: number
username: string
avatarUrl: string | null
}
export type AbsenceRequest = {
'@id'?: string
id: number
user: AbsenceUserRef
type: AbsenceType
label: string
startDate: string
endDate: string
startHalfDay: HalfDay | null
endHalfDay: HalfDay | null
countedDays: number
reason: string | null
justificationFileName: string | null
justificationUrl: string | null
status: AbsenceStatus
rejectionReason: string | null
createdAt: string
reviewedAt: string | null
reviewedBy: AbsenceUserRef | null
}
export type AbsenceRequestWrite = {
type: AbsenceType
startDate: string
endDate: string
startHalfDay?: HalfDay | null
endHalfDay?: HalfDay | null
reason?: string | null
}
export type AbsenceBalance = {
'@id'?: string
id: number
user: AbsenceUserRef
type: AbsenceType
label: string
period: string
acquired: number
acquiring: number
acquiredTotal: number
taken: number
pending: number
available: number
}
export type AbsencePolicy = {
'@id'?: string
id: number
type: AbsenceType
label: string
daysPerYear: number | null
daysPerEvent: number | null
justificationRequired: boolean
noticeDays: number
countWorkingDaysOnly: boolean
active: boolean
}
export type AbsencePolicyWrite = {
daysPerYear?: number | null
daysPerEvent?: number | null
justificationRequired?: boolean
noticeDays?: number
countWorkingDaysOnly?: boolean
active?: boolean
}
export type AbsencePreviewPayload = {
type: AbsenceType
startDate: string
endDate: string
startHalfDay?: HalfDay | null
endHalfDay?: HalfDay | null
}
export type AbsencePreviewResult = {
countedDays: number
period: string | null
available: number | null
projectedAvailable: number | null
justificationRequired: boolean
}
-42
View File
@@ -1,42 +0,0 @@
export type BookStackSettings = {
url: string | null
hasToken: boolean
}
export type BookStackSettingsWrite = {
url: string | null
tokenId: string | null
tokenSecret: string | null
}
export type BookStackTestResult = {
success: boolean
}
export type BookStackShelf = {
id: number
name: string
}
export type BookStackLink = {
id: number
bookstackId: number
bookstackType: 'page' | 'book'
title: string
url: string
createdAt: string
}
export type BookStackLinkCreate = {
bookstackId: number
bookstackType: 'page' | 'book'
title: string
url: string
}
export type BookStackSearchResult = {
id: number
type: 'page' | 'book'
name: string
url: string
}
-19
View File
@@ -1,19 +0,0 @@
export type Client = {
id: number
'@id'?: string
name: string
email: string | null
phone: string | null
street: string | null
city: string | null
postalCode: string | null
}
export type ClientWrite = {
name: string
email: string | null
phone: string | null
street: string | null
city: string | null
postalCode: string | null
}
-57
View File
@@ -1,57 +0,0 @@
export type GiteaSettings = {
url: string | null
hasToken: boolean
}
export type GiteaSettingsWrite = {
url: string | null
token: string | null
}
export type GiteaRepository = {
fullName: string
name: string
owner: string
}
export type GiteaBranch = {
name: string
commits: GiteaCommit[]
}
export type GiteaCommit = {
sha: string
message: string
author: string
date: string
}
export type GiteaBranchCreate = {
type: string
baseBranch: string
}
export type GiteaPullRequest = {
number: number
title: string
state: string
merged: boolean
headBranch: string
author: string
url: string
ciStatuses: GiteaCiStatus[]
}
export type GiteaCiStatus = {
context: string
status: string
target_url: string
}
export type GiteaBranchName = {
name: string
}
export type GiteaTestResult = {
success: boolean
}
-122
View File
@@ -1,122 +0,0 @@
// Lecture de la configuration mail (singleton admin)
export type MailConfigurationDto = {
protocol: string | null
imapHost: string | null
imapPort: number | null
imapEncryption: string | null
smtpHost: string | null
smtpPort: number | null
smtpEncryption: string | null
username: string | null
sentFolderPath: string | null
enabled: boolean
hasPassword: boolean
// password JAMAIS présent dans les réponses GET
}
// Input PATCH configuration (password optionnel, write-only)
export type MailConfigurationUpdateDto = {
protocol?: string | null
imapHost?: string | null
imapPort?: number | null
imapEncryption?: string | null
smtpHost?: string | null
smtpPort?: number | null
smtpEncryption?: string | null
username?: string | null
sentFolderPath?: string | null
enabled?: boolean
password?: string // write-only, jamais retourné
}
// Résultat du test de connexion
export type MailTestConnectionResultDto = {
ok: boolean
foldersCount?: number
error?: string
}
// Dossier mail (peut être imbriqué)
export type MailFolderDto = {
path: string
displayName: string
parentPath: string | null
unreadCount: number
totalCount: number
children?: MailFolderDto[]
}
// Adresse mail (nom + email)
export type MailAddressDto = {
name: string | null
email: string
}
// En-tête d'un message (liste)
export type MailMessageHeaderDto = {
id: number
messageId: string // identifiant IMAP unique
folderPath: string
subject: string | null
fromName: string | null
fromEmail: string | null
toRecipients: MailAddressDto[]
ccRecipients: MailAddressDto[]
sentAt: string | null // ISO 8601
receivedAt: string // ISO 8601
isRead: boolean
isFlagged: boolean
hasAttachments: boolean
linkedTaskIds: number[]
}
// Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
export type MailAttachmentDto = {
downloadId: string
filename: string
mimeType: string
size: number // octets
}
// Détail complet d'un message (enrichi avec body + PJ)
export type MailMessageDetailDto = {
header: MailMessageHeaderDto
bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
bodyText: string | null // Fallback texte plain
attachments: MailAttachmentDto[]
}
// Page de messages paginée (cursor-based)
export type MailMessagesPageDto = {
items: MailMessageHeaderDto[]
nextCursor: string | null // null = plus de page suivante
total: number
}
// Input : marquer lu/non-lu
export type MailMessageReadInput = {
read: boolean
}
// Input : marquer étoilé/non-étoilé
export type MailMessageFlagInput = {
flagged: boolean
}
// Input : créer une tâche depuis un mail
export type MailCreateTaskInput = {
projectId: number
taskGroupId?: number | null
assigneeId?: number
statusId?: number
}
// Input : lier une tâche existante à un mail
export type MailLinkTaskInput = {
taskId: number
}
// Résultat de la sync manuelle
export type MailSyncResultDto = {
dispatched: boolean
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type NotificationType = 'ticket_created' | 'ticket_status_changed'
export type NotificationType = 'task_assigned' | 'task_collaborator_added'
export type Notification = {
'@id'?: string
-33
View File
@@ -1,33 +0,0 @@
import type { Client } from './client'
import type { Workflow } from './workflow'
export type Project = {
id: number
'@id'?: string
code: string
name: string
description: string | null
color: string
client: Client | null
workflow: Workflow
giteaOwner: string | null
giteaRepo: string | null
bookstackShelfId: number | null
bookstackShelfName: string | null
archived: boolean
taskCount: number
}
export type ProjectWrite = {
code?: string
name: string
description: string | null
color: string
client: string | null // IRI : "/api/clients/1" ou null
workflow?: string // IRI : "/api/workflows/1"
giteaOwner?: string | null
giteaRepo?: string | null
bookstackShelfId?: number | null
bookstackShelfName?: string | null
archived?: boolean
}
-53
View File
@@ -1,53 +0,0 @@
export type FileEntry = {
name: string
path: string
isDir: boolean
size: number
modifiedAt: number | null
mimeType: string
}
export type Breadcrumb = {
name: string
path: string
}
export type ShareBrowseResult = {
path: string
breadcrumb: Breadcrumb[]
entries: FileEntry[]
}
export type ShareSearchResult = {
query: string
entries: FileEntry[]
}
export type ShareStatus = {
enabled: boolean
}
export type ShareSettings = {
host: string | null
shareName: string | null
basePath: string | null
domain: string | null
username: string | null
enabled: boolean
hasPassword: boolean
}
export type ShareSettingsWrite = {
host: string | null
shareName: string | null
basePath: string | null
domain: string | null
username: string | null
password?: string | null
enabled: boolean
}
export type ShareTestResult = {
success: boolean
message: string | null
}
-14
View File
@@ -1,14 +0,0 @@
import type { UserData } from './user-data'
export type TaskDocument = {
'@id'?: string
id: number
task: string
originalName: string
fileName?: string | null
sharePath?: string | null
mimeType: string
size: number
createdAt: string
uploadedBy: UserData | null
}
-9
View File
@@ -1,9 +0,0 @@
export type TaskEffort = {
id: number
'@id'?: string
label: string
}
export type TaskEffortWrite = {
label: string
}
-19
View File
@@ -1,19 +0,0 @@
import type { Project } from './project'
export type TaskGroup = {
id: number
'@id'?: string
title: string
description: string | null
color: string
project: Project | null
archived: boolean
}
export type TaskGroupWrite = {
title: string
description: string | null
color: string
project: string
archived?: boolean
}
-11
View File
@@ -1,11 +0,0 @@
export type TaskPriority = {
id: number
'@id'?: string
label: string
color: string
}
export type TaskPriorityWrite = {
label: string
color: string
}
-22
View File
@@ -1,22 +0,0 @@
export type TaskRecurrence = {
id: number
'@id'?: string
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek: string[] | null
dayOfMonth: number | null
weekOfMonth: number | null
endDate: string | null
maxOccurrences: number | null
occurrenceCount: number
}
export type TaskRecurrenceWrite = {
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek?: string[] | null
dayOfMonth?: number | null
weekOfMonth?: number | null
endDate?: string | null
maxOccurrences?: number | null
}
-21
View File
@@ -1,21 +0,0 @@
import type { StatusCategory } from './workflow'
export type TaskStatus = {
id: number
'@id'?: string
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
workflow?: { '@id': string, id: number } | string
}
export type TaskStatusWrite = {
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
workflow?: string
}
-11
View File
@@ -1,11 +0,0 @@
export type TaskTag = {
id: number
'@id'?: string
label: string
color: string
}
export type TaskTagWrite = {
label: string
color: string
}
-62
View File
@@ -1,62 +0,0 @@
import type { TaskStatus } from './task-status'
import type { TaskEffort } from './task-effort'
import type { TaskPriority } from './task-priority'
import type { TaskTag } from './task-tag'
import type { TaskGroup } from './task-group'
import type { UserData } from './user-data'
import type { Project } from './project'
import type { TaskDocument } from './task-document'
export type Task = {
id: number
'@id'?: string
number: number
title: string
description: string | null
status: TaskStatus | null
effort: TaskEffort | null
priority: TaskPriority | null
assignee: UserData | null
collaborators: UserData[]
group: TaskGroup | null
project: Project | null
tags: TaskTag[]
documents: TaskDocument[]
archived: boolean
scheduledStart: string | null
scheduledEnd: string | null
deadline: string | null
syncToCalendar: boolean
calendarSyncError: string | null
recurrence: {
id: number
'@id'?: string
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
daysOfWeek: string[] | null
dayOfMonth: number | null
weekOfMonth: number | null
endDate: string | null
maxOccurrences: number | null
occurrenceCount: number
} | null
}
export type TaskWrite = {
title: string
description: string | null
status: string | null
effort: string | null
priority: string | null
assignee: string | null
collaborators?: string[]
group: string | null
project: string
tags: string[]
archived?: boolean
scheduledStart?: string | null
scheduledEnd?: string | null
deadline?: string | null
syncToCalendar?: boolean
recurrence?: string | null
}
-28
View File
@@ -1,28 +0,0 @@
import type { UserData } from './user-data'
import type { Project } from './project'
import type { Task } from './task'
import type { TaskTag } from './task-tag'
export type TimeEntry = {
id: number
'@id'?: string
title: string | null
description: string | null
startedAt: string
stoppedAt: string | null
user: UserData
project: Project | null
task: Task | null
tags: TaskTag[]
}
export type TimeEntryWrite = {
title?: string | null
description?: string | null
startedAt: string
stoppedAt?: string | null
user: string
project?: string | null
task?: string | null
tags?: string[]
}
+1
View File
@@ -7,6 +7,7 @@ export type UserData = {
firstName?: string | null
lastName?: string | null
roles: string[]
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// HR / absence management
-46
View File
@@ -1,46 +0,0 @@
import type { TaskStatus, TaskStatusWrite } from './task-status'
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
todo: 'À faire',
in_progress: 'En cours',
blocked: 'Bloqué',
review: 'En validation',
done: 'Terminé',
}
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
todo: '#222783',
in_progress: '#4A90D9',
blocked: '#C62828',
review: '#FF8F00',
done: '#26A69A',
}
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
export function contrastText(hex: string): string {
const c = hex.replace('#', '')
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return lum > 0.6 ? '#1f2937' : '#ffffff'
}
export type Workflow = {
id: number
'@id'?: string
name: string
isDefault: boolean
position: number
statuses: TaskStatus[]
}
export type WorkflowWrite = {
name: string
isDefault: boolean
position: number
statuses?: TaskStatusWrite[]
}
-19
View File
@@ -1,19 +0,0 @@
export type ZimbraSettings = {
serverUrl: string | null
username: string | null
calendarPath: string | null
enabled: boolean
hasPassword: boolean
}
export type ZimbraSettingsWrite = {
serverUrl: string | null
username: string | null
calendarPath: string | null
password?: string | null
enabled: boolean
}
export type ZimbraTestResult = {
success: boolean
}