| 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>
11 KiB
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 M2Muser_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é
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: falseau niveau de laJoinColumn— la table n'accepte jamaissite_id IS NULLen régime nominal.onDelete: 'CASCADE'— la suppression d'un site entraîne la suppression de toutes les lignes associées. À remplacer parRESTRICT(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.
// 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 :
#[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 :
#[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 :
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ésSiteAwarenécessite que le client envoie systématiquementsitedans 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
ContactaussiSiteAware(redondance mais simple). - Ajouter un filtre custom qui joint sur
contact.client.siteet compare aucurrentSite.
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
currentSitepositionné (cas nominal). - Soit utiliser un user avec
sites.bypass_scopepour 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 avecnullable: 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 :
- Modifier
src/Module/Commercial/Domain/Entity/Supplier.php: ajouterimplements SiteAwareInterface+ relation$site. - Créer migration
Version<timestamp>.php:ALTER TABLE supplier ADD site_id ..., backfill,SET NOT NULL,CREATE INDEX. - Ajouter
#[Groups(['supplier:read'])]sur$site. - Mettre à jour les fixtures
CommercialFixturespour rattacher chaque supplier à un site (setSite(...)). - Ajouter un test d'intégration qui vérifie que la collection
/api/suppliersretourne bien uniquement les suppliers du site courant pour un user donné. - 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.