feat(sites) : API CRUD + rattachement User<->Site + admin (ticket 2/4)

Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage),
relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable,
ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource
virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor
etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select
premier site si null + sites non vide.

Page /admin/sites (DataTable + drawer creation/edition + modale suppression).
UserRbacDrawer etendu avec section "Sites autorises". Colonne "Sites" ajoutee
dans la table /admin/users (liste des noms separes par virgule). Sidebar
entree Sites (module: sites, permission: sites.view).

Refactor adresse : split full_address en street + complement (nullable) + getter
computed Site::getFullAddress() multi-lignes. Migration ALTER dediee pour
compat devs ayant deja joue le ticket 1. Fixtures avec vraies adresses
(Chatellerault/Fontenet/Pommevic).

Doctrine : inversedBy synchrone User.sites <-> Site.users pour maintenir la
collection inverse en memoire. User::switchCurrentSite() porte la garde
domaine (throw SiteNotAuthorizedException), aligne sur Role::ensureDeletable.
Helper skipIfSitesModuleDisabled centralise dans AbstractApiTestCase.

Tests : 182/182 (182/182 aussi module desactive, 2 skipped). 29 nouveaux tests
PHPUnit (CRUD API, switch currentSite, cascade DB, /api/me enrichi, extension
/rbac, gardes structurelles fullAddress/currentSite ignores, anti-cycle
Site.users). 11 tests Vitest sur la validation hex couleur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 10:09:05 +02:00
parent 105574ba2f
commit d137828919
32 changed files with 2271 additions and 117 deletions

View File

@@ -15,6 +15,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -107,6 +109,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les
* voters futurs aient toujours la collection hydratee, meme dans un
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste
* negligeable (≤ quelques sites par user en pratique).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
* de site actif (par ex. apres creation avant premier login). La FK porte
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
* les users qui le pointaient — ils repassent simplement a `null`.
*
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*/
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list'])]
private ?Site $currentSite = null;
#[ORM\Column]
private ?string $password = null;
@@ -121,6 +156,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->sites = new ArrayCollection();
}
public function getId(): ?int
@@ -313,4 +349,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
$this->plainPassword = null;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
/**
* Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
* Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1).
*/
public function addSite(Site $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
$site->addUser($this);
}
return $this;
}
/**
* Retire un site de la collection + maintient la collection inverse en
* memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
* si le site retire en etait le courant — cet invariant est enforce
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(Site $site): static
{
if ($this->sites->removeElement($site)) {
$site->removeUser($this);
}
return $this;
}
/**
* Garde applicative rapide : teste la presence d'un site dans la
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(Site $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?Site
{
return $this->currentSite;
}
/**
* Setter brut, sans garde. Usage interne pour les flux qui doivent
* pouvoir positionner un site arbitraire ou null (reset de coherence
* post-PATCH RBAC, fixtures, init). Pour le flux user-facing
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?Site $currentSite): static
{
$this->currentSite = $currentSite;
return $this;
}
/**
* Garde domaine du switch utilisateur : refuse un site qui n'est pas
* dans la collection autorisee. Levee d'une exception domaine que le
* processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
* → SystemRoleDeletionException).
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(Site $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site);
}
$this->currentSite = $site;
}
}