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