# 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.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.