Files
Coltura/docs/modules/site-aware.md
tristan 296befe187 feat(sites) : outillage opt-in site-aware (ticket 4/4)
Livre l'infrastructure permettant aux modules metier de declarer leurs
entites comme "scopees par site" via SiteAwareInterface. Strictement
opt-in : aucune entite metier touchee, aucune migration sur tables
existantes.

Composants :
- SiteAwareInterface (Shared/Domain/Contract) : getSite/setSite
- CurrentSiteProvider + interface (Module/Sites/Application) : resolve
  ?Site selon 3 conditions (module actif, user authentifie, currentSite).
  Interface extraite pour mockabilite en tests (implementation reste final).
- SiteScopedQueryExtension : QueryCollection + QueryItem API Platform,
  ajoute WHERE site = :currentSite si resource SiteAware + provider
  non-null + pas sites.bypass_scope.
- SiteAwareInjectionProcessor : decorator de api_platform.doctrine.orm.
  state.persist_processor (#[AsDecorator]). Injecte currentSite sur
  entites SiteAware sans site ; throw 400 si provider null.
- Permission sites.bypass_scope declaree dans SitesModule::permissions().

Tests :
- FakeSiteAwareEntity dans tests/Fixtures/ + mapping when@test dans
  doctrine.yaml. Table creee a la volee via SchemaTool dans setUp.
  schema:update --force ajoute dans test-db-setup pour que fixtures:load
  ne crashe pas au purger.
- 17 tests dedies au ticket 4 (CurrentSiteProvider unitaire, Injection
  Processor unitaire, Extension integration avec 7 cas couvrant filtrage
  collection + item, bypass, no-op, resource non SiteAware).
- SitesModuleTest : verifie le set de 3 permissions + que le decorator
  est bien enregistre sur le persist processor.

Documentation docs/modules/site-aware.md : guide developpeur 8 sections
(quand/ne pas adopter, comment, migration, mode degrade, anti-patterns,
exemple d'adoption Supplier, cascade delete).

Upgrade @malio/layer-ui 1.4.0 → 1.4.2 (bug 1.4.0 : tailwind.config.ts
oublie dans les files publies npm → classe rounded-malio manquante sur
les DataTables). Simplification tailwind.config.ts Coltura : retrait des
colors/fontFamily/borderRadius dupliques, seule la specifique projet
(primary, secondary, tertiary, m.secondary, m.tertiary) est conservee.

Tests : 201/201 avec et sans SitesModule actif (2 skipped en disabled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:11:07 +02:00

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 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é

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.

// 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.
  • SiteAwareInjectionProcessorthrow 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.