8313c759c6
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
204 lines
11 KiB
Markdown
204 lines
11 KiB
Markdown
# 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 = <nouveau 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.
|