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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user