Files
Coltura/docs/modules/site-aware.md
tristan 6cf5ef4cfc
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Module sites (#8)
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 15:31:58 +00:00

288 lines
11 KiB
Markdown

# Guide développeur — `SiteAwareInterface` (opt-in)
Ce guide explique comment adopter le pattern **site-aware** sur une entité
d'un module métier pour que ses données soient automatiquement filtrées
par le site courant de l'utilisateur connecté, et pour que les créations
soient rattachées implicitement au site courant.
Ce pattern est **opt-in strict** : aucune entité n'est affectée tant qu'un
module ne choisit pas explicitement d'implémenter `SiteAwareInterface`.
Livré par le ticket 4/4 du module Sites (cf. `docs/sites/ticket-04-spec.md`).
## 1. Quand adopter ?
Adopte le pattern si :
- Chaque ligne de l'entité appartient à **un et un seul site**.
- Les utilisateurs du site A ne doivent **jamais** voir les lignes du site B.
- Créer une ligne sans connaître le site n'a pas de sens métier.
Exemples typiques : `Supplier`, `Order`, `StockEntry`, `Employee` (si chaque
site a sa propre équipe), `Invoice` (si facturation par site).
## 2. Quand NE PAS adopter ?
**Entités globales** : partagées par tous les sites, pas de notion de
propriétaire. Ne pas adopter.
- `Role`, `Permission`, `User` (les users sont transverses, rattachés à
plusieurs sites via la relation M2M `user_site`).
- Catalogues mutualisés : produits, catégories, taxes — sauf si chaque
site a son propre catalogue.
- Documents / contrats multi-site (ex: contrat-cadre qui couvre plusieurs
sites).
**Entités "par tenant"** : si le scope naturel est plus large que le site
(ex: un groupe qui possède plusieurs sites comme entités filiales
juridiquement distinctes), utilise plutôt `TenantAwareInterface` (déjà
présent dans `src/Shared/Domain/Contract/`).
**Entités hybrides** : certaines lignes globales, d'autres par site. Le
pattern ne supporte pas ce cas — crée deux entités distinctes si
nécessaire.
## 3. Comment adopter ? Check-list
### 3.1 Entité
```php
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteAwareInterface;
class Supplier implements SiteAwareInterface
{
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Site $site = null;
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(Site $site): void
{
$this->site = $site;
}
}
```
Points critiques :
- `nullable: false` au niveau de la `JoinColumn` — la table n'accepte
jamais `site_id IS NULL` en régime nominal.
- `onDelete: 'CASCADE'` — la suppression d'un site entraîne la suppression
de toutes les lignes associées. À remplacer par `RESTRICT` (blocage) si
ton métier exige d'empêcher la suppression d'un site contenant des
données.
- Le getter retourne `?Site` (nullable) pour permettre des états
transitoires pré-persist (entité construite avant injection du site).
### 3.2 Migration
**Cas 1 — Nouvelle table** : ajoute directement `site_id INT NOT NULL`
avec FK et index.
**Cas 2 — Table existante avec données legacy** : migration en trois étapes
distinctes.
```php
// Version1.php
$this->addSql('ALTER TABLE supplier ADD site_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_supplier_site ON supplier (site_id)');
$this->addSql('ALTER TABLE supplier ADD CONSTRAINT FK_supplier_site FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE');
// Backfill (manuellement ou via script custom selon ton métier)
$this->addSql("UPDATE supplier SET site_id = (SELECT id FROM site WHERE name = 'Chatellerault') WHERE site_id IS NULL");
// Version2.php — après backfill confirmé
$this->addSql('ALTER TABLE supplier ALTER COLUMN site_id SET NOT NULL');
```
**Index obligatoire** : le filtre généré par `SiteScopedQueryExtension`
est `WHERE x.site = :currentSite`. Sans index sur `site_id`, chaque
requête fait un full-scan de la table. Ajoute-le dans la migration.
### 3.3 Sérialisation API
Expose la relation `site` dans le groupe de lecture de la ressource pour
que le frontend sache à quel site appartient chaque ligne :
```php
#[Groups(['supplier:read'])]
private ?Site $site = null;
```
Si tu veux aussi permettre à un admin de créer une ligne sur un autre
site que son `currentSite` (ex: admin multi-site), ajoute aussi le groupe
d'écriture :
```php
#[Groups(['supplier:read', 'supplier:write'])]
```
Dans ce cas, `SiteAwareInjectionProcessor` respecte la valeur explicite
envoyée par le client (voir §4).
### 3.4 Processor custom
Si le module a déjà un processor custom sur les opérations POST/PATCH,
assure-toi qu'il délègue à `api_platform.doctrine.orm.state.persist_processor`
(et non à `$em->persist()` direct) pour que le decorator
`SiteAwareInjectionProcessor` s'applique.
Pattern aligné sur `UserRbacProcessor` :
```php
use Symfony\Component\DependencyInjection\Attribute\Autowire;
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
// Gardes métier custom ici...
// Délègue au persist processor décoré : SiteAwareInjectionProcessor
// interceptera l'appel et injectera le currentSite si besoin.
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
```
## 4. Comportement du processor d'injection
Le decorator `SiteAwareInjectionProcessor` s'applique automatiquement à
**toute** persistance API Platform. Son comportement :
| Cas | Action |
|---|---|
| `$data` n'implémente pas `SiteAwareInterface` | Délégation directe (no-op). |
| `$data` est `SiteAware` avec `$site` déjà positionné (ex: payload POST avec `site` explicite) | Délégation directe, **la valeur explicite est préservée**. |
| `$data` est `SiteAware` sans site, `CurrentSiteProvider::get()` retourne un `Site` | Injection `$data->setSite($currentSite)` puis délégation. |
| `$data` est `SiteAware` sans site, `CurrentSiteProvider::get()` retourne `null` | **Throw `BadRequestHttpException`** avec message "aucun site sélectionné". |
Conséquence : un user sans `currentSite` ne peut **pas** créer de ligne
sur une entité `SiteAware`. C'est intentionnel : mieux vaut un 400 clair
que persister une ligne incohérente.
## 5. Comportement en mode dégradé
### 5.1 Module Sites désactivé
Si `SitesModule::class` est retiré de `config/modules.php`,
`CurrentSiteProvider::get()` retourne **toujours `null`** :
- `SiteScopedQueryExtension` → no-op. Toutes les lignes visibles, comme
si le filtre n'existait pas.
- `SiteAwareInjectionProcessor`**throw 400 sur tout POST/PATCH** sans
site explicite. L'écriture d'entités `SiteAware` nécessite que le
client envoie systématiquement `site` dans le payload.
**Conséquence** : un module qui adopte le pattern **ne peut pas vivre**
sans le module Sites actif pour les opérations d'écriture. À documenter
fortement dans le README du module adopté.
### 5.2 User sans site (sites = [], currentSite = null)
Même comportement qu'un module désactivé : lecture no-op (tout visible),
écriture bloquée par 400. L'UX doit gérer ce cas (ex: écran d'onboarding
qui force l'assignation d'un site avant d'accéder aux écrans métier).
### 5.3 Bypass admin via `sites.bypass_scope`
Un utilisateur avec la permission `sites.bypass_scope` (ou admin par
bypass total via `isAdmin = true`) voit **toutes** les lignes, tous
sites confondus. Pratique pour audit, reporting, consolidation groupe.
Le processor d'injection ne respecte **pas** ce bypass : même un user
avec `bypass_scope` verra son `currentSite` injecté à la création s'il
n'envoie pas de `site` explicite. Le bypass est un droit de lecture,
pas d'écriture multi-site.
## 6. Anti-patterns et gotchas
### 6.1 Sous-collections (`/api/clients/{id}/contacts`)
Si seul `Client` est `SiteAware` (et `Contact` hérite du scope via son
parent), le filtre **ne se propage pas automatiquement** aux contacts.
Deux options :
- Rendre `Contact` aussi `SiteAware` (redondance mais simple).
- Ajouter un filtre custom qui joint sur `contact.client.site` et compare
au `currentSite`.
Ce ticket ne couvre pas le second cas : à implémenter par le module
concerné.
### 6.2 Repositories custom
Le filtre API Platform ne s'applique **qu'aux requêtes** générées par
API Platform (via `ItemProvider` / `CollectionProvider` Doctrine). Si un
repository custom fait une requête DQL manuelle (ex: `findTopRated()`
appelé depuis un service), **aucun filtre n'est appliqué**.
Responsabilité du développeur du module : injecter `CurrentSiteProvider`
dans le repository / service et ajouter manuellement la clause WHERE.
### 6.3 Tests d'intégration
Les tests qui persistent des entités `SiteAware` doivent :
- Soit logger un user avec un `currentSite` positionné (cas nominal).
- Soit utiliser un user avec `sites.bypass_scope` pour voir toutes les
lignes (cas reporting).
- Soit positionner le site **explicitement** sur chaque entité persistée
via fixture (bypass du processor d'injection qui n'est pas actif hors
contexte HTTP).
### 6.4 Cascade delete d'un site
La migration type du §3.2 déclare `onDelete: 'CASCADE'` sur la FK
`site_id`. **Conséquence** : supprimer un site détruit **toutes** les
lignes de **toutes** les tables `SiteAware` rattachées à ce site, en
cascade. Pour un `Supplier`, ça signifie perte de l'historique fournisseur
du site supprimé.
Alternatives selon le besoin métier :
- `onDelete: 'RESTRICT'` : bloque la suppression du site tant qu'il reste
des lignes. L'admin doit nettoyer manuellement avant delete.
- `onDelete: 'SET NULL'` : transforme les lignes en "globales" après
suppression du site — mais incompatible avec `nullable: false`, donc
nécessite de relâcher la contrainte. Généralement à éviter.
## 7. Exemple d'adoption minimale (pseudo-ticket)
Pour un futur ticket qui adopte `SiteAwareInterface` sur `Supplier` du
module Commercial :
1. Modifier `src/Module/Commercial/Domain/Entity/Supplier.php` : ajouter
`implements SiteAwareInterface` + relation `$site`.
2. Créer migration `Version<timestamp>.php` : `ALTER TABLE supplier ADD
site_id ...`, backfill, `SET NOT NULL`, `CREATE INDEX`.
3. Ajouter `#[Groups(['supplier:read'])]` sur `$site`.
4. Mettre à jour les fixtures `CommercialFixtures` pour rattacher chaque
supplier à un site (`setSite(...)`).
5. Ajouter un test d'intégration qui vérifie que la collection
`/api/suppliers` retourne bien uniquement les suppliers du site
courant pour un user donné.
6. Documenter dans le README du module Commercial que les opérations
d'écriture sur Supplier nécessitent le module Sites actif + un user
avec `currentSite`.
## 8. Permission `sites.bypass_scope`
Déclarée par `SitesModule::permissions()`, synchronisée automatiquement
en base par `app:sync-permissions`. Une fois synchronisée, elle est
assignable :
- Directement à un user via `/admin/users` (drawer RBAC, section
"Permissions directes").
- Via un rôle personnalisé (ex: rôle "Auditeur groupe") qui la porte.
Les admins l'obtiennent automatiquement par bypass total (`isAdmin`), pas
besoin d'assignation explicite.