All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| 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>
288 lines
11 KiB
Markdown
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.
|