['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']], )] // Filtres cote API pour /admin/sites : recherche partielle insensible a // la casse (SQL ILIKE %x%) sur les champs texte saisis dans les headers // de la DataTable. postalCode est purement numerique donc le I/partial // donne le meme resultat, mais on reste coherent avec name/city. #[ApiFilter(SearchFilter::class, properties: [ 'name' => 'ipartial', 'city' => 'ipartial', 'postalCode' => 'ipartial', ])] #[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)] #[ORM\Table(name: 'site')] #[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])] #[ORM\HasLifecycleCallbacks] #[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')] class Site implements SiteInterface { #[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; // 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 // code postal francais strict : 5 chiffres numeriques. #[ORM\Column(name: 'postal_code', length: 10)] #[Assert\NotBlank(message: 'Le code postal est requis.')] #[Assert\Length(max: 10, maxMessage: 'Le code postal ne peut pas depasser {{ limit }} caracteres.')] #[Assert\Regex( 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 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; // 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 */ #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')] private Collection $users; public function __construct( string $name, string $street, ?string $complement, string $postalCode, string $city, string $color, ) { $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(); } /** * Callback Doctrine : a chaque update en base on rafraichit updatedAt. * Ne pas toucher a createdAt ici (immutable apres creation). */ #[ORM\PreUpdate] public function onPreUpdate(): void { $this->updatedAt = new DateTimeImmutable(); } public function getId(): ?int { return $this->id; } public function getName(): string { return $this->name; } public function setName(string $name): static { $this->name = $name; return $this; } public function getStreet(): string { return $this->street; } public function setStreet(string $street): static { $this->street = $street; return $this; } public function getComplement(): ?string { return $this->complement; } public function setComplement(?string $complement): static { $this->complement = $complement; return $this; } public function getPostalCode(): string { return $this->postalCode; } public function setPostalCode(string $postalCode): static { $this->postalCode = $postalCode; 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; } public function setColor(string $color): static { $this->color = $color; 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 { $lines = [$this->street]; if (null !== $this->complement && '' !== trim($this->complement)) { $lines[] = $this->complement; } $lines[] = sprintf('%s %s', $this->postalCode, $this->city); return implode("\n", $lines); } public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; } public function getUpdatedAt(): DateTimeImmutable { return $this->updatedAt; } /** * @return Collection */ 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; } }