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;
}
}

View File

@@ -29,6 +29,14 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface.
* - Coherence currentSite (ticket 2 module Sites) : apres persist des
* sites autorises, si le `currentSite` n'est plus dans la collection,
* il est repositionne automatiquement :
* a) repasse a `null` s'il pointait vers un site retire ;
* b) est auto-selectionne sur le premier site de `sites` s'il etait
* null alors que la collection n'est pas vide (pratique pour un
* premier rattachement).
* Un second flush est emis uniquement si la coherence a du etre corrigee.
*
* @implements ProcessorInterface<User, User>
*/
@@ -80,6 +88,46 @@ final class UserRbacProcessor implements ProcessorInterface
}
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement.
$this->ensureCurrentSiteConsistency($data);
return $result;
}
/**
* Applique deux corrections post-persist sur `currentSite` :
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant).
*
* N'emet un flush additionnel que si une correction a ete necessaire :
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
* aux sites.
*/
private function ensureCurrentSiteConsistency(User $user): void
{
$currentSite = $user->getCurrentSite();
$sites = $user->getSites();
$changed = false;
if (null !== $currentSite && !$user->hasSite($currentSite)) {
$user->setCurrentSite(null);
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
$user->setCurrentSite($sites->first() ?: null);
$changed = true;
}
if ($changed) {
$this->entityManager->flush();
}
}
}

View File

@@ -8,26 +8,50 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034,
* puis (ticket 2 module Sites) rattaches a au moins un site avec un currentSite
* coherent.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
* casse l'independance declaree au ticket 1 : c'est un couplage assume car
* apres ticket 2 le modele metier exprime un besoin legitime de rattachement.
*/
class AppFixtures extends Fixture
class AppFixtures extends Fixture implements DependentFixtureInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
// SitesFixtures doit tourner AVANT AppFixtures pour que les sites
// soient disponibles au rattachement des users ci-dessous.
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
@@ -43,23 +67,43 @@ class AppFixtures extends Fixture
'Role de base sans permission specifique',
);
// Recupere les 3 sites seedes par SitesFixtures. Si absents, c'est
// une misconfiguration (fixture hors purge ou dependance ignoree) :
// on fail fort avec un message explicite plutot que de continuer
// avec des users orphelins de site.
$chatellerault = $this->requireSite('Chatellerault');
$saintJean = $this->requireSite('Saint-Jean');
$pommevic = $this->requireSite('Pommevic');
$admin = new User();
$admin->setUsername('admin');
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
// Admin rattache aux 3 sites pour faciliter le dev / les tests manuels.
$admin->addSite($chatellerault);
$admin->addSite($saintJean);
$admin->addSite($pommevic);
$admin->setCurrentSite($chatellerault);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
// Alice : un seul site, site courant = ce site.
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
// Bob : site different de Alice, pour prouver le filtrage par site
// dans les futurs tests (ticket 4 outillage SiteAware).
$bob->addSite($saintJean);
$bob->setCurrentSite($saintJean);
$manager->persist($bob);
$manager->flush();
@@ -90,4 +134,19 @@ class AppFixtures extends Fixture
return $role;
}
private function requireSite(string $name): Site
{
$site = $this->siteRepository->findByName($name);
if (null === $site) {
throw new RuntimeException(sprintf(
'SitesFixtures doit avoir seede le site "%s" avant le chargement des users. '
.'Verifier que SitesFixtures est bien en dependance de AppFixtures.',
$name,
));
}
return $site;
}
}

View File

@@ -4,22 +4,63 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
*
* Brique fondatrice du module Sites : cette entite n'est pas exposee par
* une ressource API Platform dans ce ticket (ticket 1/4). Elle sert de socle
* de donnees aux tickets suivants (rattachement utilisateurs, affichage
* navbar, etc.). Aucune dependance dure depuis le module Core : la table
* est creee meme si le module est desactive (voir migration dediee).
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
* permettre des recherches/tris fins ulterieurs et eviter les divergences
* entre champs duplique. La methode `getFullAddress()` fournit la version
* concatenee multi-lignes pour les usages d'affichage.
*
* Expose en API Platform pour l'administration CRUD avec RBAC :
* - lecture (GET list / item) : requiert la permission `sites.view`
* - ecriture (POST / PATCH / DELETE) : requiert la permission `sites.manage`
*
* Egalement embarque dans la reponse `/api/me` (groupe `me:read`) pour que
* le frontend connaisse les sites autorises et le site courant de l'user.
*/
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Get(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Post(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Patch(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Delete(security: "is_granted('sites.manage')"),
],
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
)]
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
#[ORM\Table(name: 'site')]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
@@ -30,17 +71,27 @@ class Site
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['site:read', 'me:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
#[Assert\Length(max: 100, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $name;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
private string $city;
// Premiere ligne d'adresse : numero + voie. Requise.
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est requise.')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $street;
// Complement d'adresse optionnel : batiment, escalier, BP, etc.
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complement ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private ?string $complement = null;
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
// colonnes en minuscules dans le SQL brut). Le format est contraint au
@@ -52,50 +103,71 @@ class Site
pattern: '/^\d{5}$/',
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
)]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $postalCode;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $city;
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
// incluant le diese). Utilisee plus tard par la navbar (ticket 3) pour
// distinguer les sites d'un coup d'oeil.
// incluant le diese). Utilisee par la navbar (ticket 3) pour distinguer
// les sites d'un coup d'oeil.
#[ORM\Column(length: 7)]
#[Assert\NotBlank(message: 'La couleur est requise.')]
#[Assert\Regex(
pattern: '/^#[0-9A-Fa-f]{6}$/',
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
)]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $color;
// Champ TEXT volontaire : l'adresse complete peut courir sur plusieurs
// lignes (voie + complement + mention particuliere). Borne haute a 500
// caracteres : une adresse francaise complete tient tres largement dans
// cette enveloppe, et la limite applicative protege contre les payloads
// anormalement volumineux envoyes par un client (garde DoS basique).
#[ORM\Column(name: 'full_address', type: Types::TEXT)]
#[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
#[Assert\Length(max: 500, maxMessage: 'L\'adresse complete ne peut pas depasser {{ limit }} caracteres.')]
private string $fullAddress;
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
#[Groups(['site:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
#[Groups(['site:read'])]
private DateTimeImmutable $updatedAt;
/**
* Collection inverse des users rattaches a ce site.
*
* Volontairement SANS `#[Groups]` : la collection n'est jamais exposee via
* l'API pour deux raisons :
* - eviter une boucle de serialisation infinie User → sites → users → ...
* si un jour un developpeur ajoute `me:read` ici par megarde ;
* - l'inverse n'a de valeur qu'en interne (compter les users d'un site,
* iterer en test de cascade).
*
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
private Collection $users;
public function __construct(
string $name,
string $city,
string $street,
?string $complement,
string $postalCode,
string $city,
string $color,
string $fullAddress,
) {
$this->name = $name;
$this->city = $city;
$this->postalCode = $postalCode;
$this->color = $color;
$this->fullAddress = $fullAddress;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->name = $name;
$this->street = $street;
$this->complement = $complement;
$this->postalCode = $postalCode;
$this->city = $city;
$this->color = $color;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->users = new ArrayCollection();
}
/**
@@ -125,14 +197,26 @@ class Site
return $this;
}
public function getCity(): string
public function getStreet(): string
{
return $this->city;
return $this->street;
}
public function setCity(string $city): static
public function setStreet(string $street): static
{
$this->city = $city;
$this->street = $street;
return $this;
}
public function getComplement(): ?string
{
return $this->complement;
}
public function setComplement(?string $complement): static
{
$this->complement = $complement;
return $this;
}
@@ -149,6 +233,18 @@ class Site
return $this;
}
public function getCity(): string
{
return $this->city;
}
public function setCity(string $city): static
{
$this->city = $city;
return $this;
}
public function getColor(): string
{
return $this->color;
@@ -161,16 +257,26 @@ class Site
return $this;
}
/**
* Adresse complete reconstituee : street, [complement,] {CP} {ville},
* separes par des sauts de ligne. Methode pure, jamais persistee.
*
* Expose en lecture API (groupes site:read + me:read) pour que les
* consommateurs (frontend, exports PDF) recoivent une adresse prete a
* afficher sans dupliquer la logique de concatenation cote client.
*/
#[Groups(['site:read', 'me:read'])]
public function getFullAddress(): string
{
return $this->fullAddress;
}
$lines = [$this->street];
public function setFullAddress(string $fullAddress): static
{
$this->fullAddress = $fullAddress;
if (null !== $this->complement && '' !== trim($this->complement)) {
$lines[] = $this->complement;
}
return $this;
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
return implode("\n", $lines);
}
public function getCreatedAt(): DateTimeImmutable
@@ -182,4 +288,39 @@ class Site
{
return $this->updatedAt;
}
/**
* @return Collection<int, User>
*/
public function getUsers(): Collection
{
return $this->users;
}
/**
* Synchronise la collection inverse cote Site quand User::addSite est
* appele. Idempotent. Ne re-appelle pas $user->addSite($this) pour
* eviter une recursion infinie : User::addSite est le point d'entree
* unique de la mutation.
*
* @internal Appele uniquement par User::addSite()
*/
public function addUser(User $user): static
{
if (!$this->users->contains($user)) {
$this->users->add($user);
}
return $this;
}
/**
* @internal Appele uniquement par User::removeSite()
*/
public function removeUser(User $user): static
{
$this->users->removeElement($user);
return $this;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Domain\Exception;
use App\Module\Sites\Domain\Entity\Site;
use DomainException;
/**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
* site qui ne fait pas partie de ses sites autorises.
*
* Exception purement domaine : la traduction HTTP (403) est faite par le
* CurrentSiteProcessor via try/catch, aligne sur le pattern
* SystemRoleDeletionException du module Core.
*/
final class SiteNotAuthorizedException extends DomainException
{
public static function forSite(Site $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\CurrentSiteProcessor;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Ressource API Platform virtuelle (non mappee Doctrine) qui porte
* l'operation `PATCH /api/me/current-site` : basculement du site courant
* de l'utilisateur authentifie.
*
* `read: false` informe API Platform qu'il ne doit pas tenter de charger
* une entite existante via un Provider — l'operation denormalise le payload
* directement dans cette ressource, puis CurrentSiteProcessor prend le relais.
*
* `shortName: 'CurrentSite'` : evite toute collision avec l'entite `Site`
* dans le routage et la documentation OpenAPI.
*
* Securite : l'autorisation "ROLE_USER" suffit au niveau voter — la verification
* fine (le site demande fait-il partie des sites autorises de l'user ?)
* est faite par CurrentSiteProcessor, car elle dependence de l'user
* authentifie, pas d'une permission statique.
*/
#[ApiResource(
shortName: 'CurrentSite',
operations: [
new Patch(
uriTemplate: '/me/current-site',
security: "is_granted('ROLE_USER')",
normalizationContext: ['groups' => ['me:read']],
denormalizationContext: ['groups' => ['current-site:write']],
processor: CurrentSiteProcessor::class,
read: false,
priority: 1,
),
],
)]
final class CurrentSiteResource
{
/**
* Site cible du switch, denormalise depuis l'IRI envoye dans le body :
* `{ "site": "/api/sites/{id}" }`. Resolu automatiquement par
* l'IriConverter d'API Platform en instance de `Site`.
*/
#[Groups(['current-site:write'])]
public ?Site $site = null;
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor de l'operation `PATCH /api/me/current-site`.
*
* Flux :
* 1. Recupere l'user authentifie via Security.
* 2. Extrait le site cible depuis la ressource denormalisee.
* 3. Valide que le site fait partie des `sites` de l'user — sinon leve
* SiteNotAuthorizedException convertie immediatement en 403.
* 4. Positionne `currentSite`, flush, retourne l'user pour normalisation
* par API Platform via les groupes `me:read` (payload identique a /api/me).
*
* @implements ProcessorInterface<CurrentSiteResource, User>
*/
final class CurrentSiteProcessor implements ProcessorInterface
{
public function __construct(
private readonly Security $security,
private readonly EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CurrentSiteResource) {
throw new LogicException(sprintf(
'CurrentSiteProcessor attend une instance de %s, %s recu.',
CurrentSiteResource::class,
get_debug_type($data),
));
}
$user = $this->security->getUser();
if (!$user instanceof User) {
// security: "is_granted('ROLE_USER')" sur l'operation doit deja
// bloquer ce cas — garde defensive si la config change.
throw new AccessDeniedHttpException('Authentification requise pour changer de site courant.');
}
$targetSite = $data->site;
if (null === $targetSite) {
throw new BadRequestHttpException('Le champ "site" est requis.');
}
try {
$user->switchCurrentSite($targetSite);
} catch (SiteNotAuthorizedException $e) {
// Traduction HTTP immediate (pas de listener kernel necessaire) :
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->flush();
return $user;
}
}

View File

@@ -40,38 +40,43 @@ class SitesFixtures extends Fixture
$this->ensureSite(
$manager,
name: 'Chatellerault',
city: 'Chatellerault',
street: "14 All. d'Argenson",
complement: null,
postalCode: '86100',
city: 'Châtellerault',
color: '#056CF2',
fullAddress: "1 avenue de l'Europe\n86100 Chatellerault",
);
// Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault.
// Note : le nom du site (identifier) ne reflete pas la ville reelle
// (Fontenet) — c'est une nomenclature interne client.
$this->ensureSite(
$manager,
name: 'Saint-Jean',
city: 'Saint-Jean-de-Sauves',
postalCode: '86330',
street: 'Z i',
complement: null,
postalCode: '17400',
city: 'Fontenet',
color: '#10B981',
fullAddress: "12 route de Poitiers\n86330 Saint-Jean-de-Sauves",
);
// Pommevic : ambre pour une troisieme teinte nettement distincte.
$this->ensureSite(
$manager,
name: 'Pommevic',
city: 'Pommevic',
street: '1 Av. Jean Duquesne',
complement: null,
postalCode: '82400',
city: 'Pommevic',
color: '#F59E0B',
fullAddress: "5 chemin des Peupliers\n82400 Pommevic",
);
$manager->flush();
}
/**
* Cree le site s'il n'existe pas encore, sinon re-aligne ville, code
* postal, couleur et adresse sur les valeurs de reference.
* Cree le site s'il n'existe pas encore, sinon re-aligne rue, complement,
* code postal, ville et couleur sur les valeurs de reference.
*
* Note : le nom sert de cle de lookup (il est unique en base) et n'est
* donc pas resynchronise. Consequence : renommer un site dans la
@@ -81,24 +86,26 @@ class SitesFixtures extends Fixture
private function ensureSite(
ObjectManager $manager,
string $name,
string $city,
string $street,
?string $complement,
string $postalCode,
string $city,
string $color,
string $fullAddress,
): Site {
$site = $this->siteRepository->findByName($name);
if (null === $site) {
$site = new Site($name, $city, $postalCode, $color, $fullAddress);
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
$manager->persist($site);
return $site;
}
$site->setCity($city);
$site->setStreet($street);
$site->setComplement($complement);
$site->setPostalCode($postalCode);
$site->setCity($city);
$site->setColor($color);
$site->setFullAddress($fullAddress);
return $site;
}