['role:read']], security: "is_granted('core.roles.view')", ), new Get( normalizationContext: ['groups' => ['role:read']], security: "is_granted('core.roles.view')", ), new Post( normalizationContext: ['groups' => ['role:read']], denormalizationContext: ['groups' => ['role:write']], security: "is_granted('core.roles.manage')", processor: RoleProcessor::class, ), new Patch( normalizationContext: ['groups' => ['role:read']], denormalizationContext: ['groups' => ['role:write']], security: "is_granted('core.roles.manage')", processor: RoleProcessor::class, ), new Delete( security: "is_granted('core.roles.manage')", processor: RoleProcessor::class, ), ], normalizationContext: ['groups' => ['role:read']], denormalizationContext: ['groups' => ['role:write']], )] #[ApiFilter(BooleanFilter::class, properties: ['isSystem'])] #[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)] #[ORM\Table(name: '`role`')] #[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])] #[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])] #[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')] class Role { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['role:read'])] private ?int $id = null; #[ORM\Column(length: 100)] #[Groups(['role:read', 'role:write'])] #[Assert\NotBlank] #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')] private string $code; #[ORM\Column(length: 255)] #[Groups(['role:read', 'role:write'])] #[Assert\NotBlank] private string $label; #[ORM\Column(type: Types::TEXT, nullable: true)] #[Groups(['role:read', 'role:write'])] private ?string $description = null; // Volontairement exclu du groupe `role:write` : un client ne doit jamais // pouvoir positionner ce flag via l'API. Seules les fixtures et migrations // creent les roles systeme. #[ORM\Column(name: 'is_system', options: ['default' => false])] #[Groups(['role:read'])] private bool $isSystem = false; /** @var Collection */ // Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) : // - Evite un lazy-load silencieux pendant un refresh de token JWT ou une // serialisation hors contexte EntityManager (voir ticket #343, section // 11 risque #1) ou la collection serait inaccessible et provoquerait // une erreur opaque. // - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle // d'un CRM/ERP PME ou un role porte quelques dizaines de permissions. // - Si la volumetrie augmente significativement : revoir vers une // projection cachee (ticket a ouvrir a ce moment-la). #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')] #[ORM\JoinTable(name: 'role_permission')] #[Groups(['role:read', 'role:write'])] private Collection $permissions; public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null) { $this->code = $code; $this->label = $label; $this->isSystem = $isSystem; $this->description = $description; $this->permissions = new ArrayCollection(); } public function getId(): ?int { return $this->id; } public function getCode(): string { return $this->code; } public function getLabel(): string { return $this->label; } public function getDescription(): ?string { return $this->description; } // Le getter est annote directement car la convention Symfony PropertyInfo // strip le prefixe `is` et exposerait le champ sous le nom `system`. On // pose donc un SerializedName explicite pour garantir la sortie JSON-LD // sous `isSystem`, nom attendu par les clients de l'API. #[Groups(['role:read'])] #[SerializedName('isSystem')] public function isSystem(): bool { return $this->isSystem; } /** @return Collection */ public function getPermissions(): Collection { return $this->permissions; } /** * Setter expose uniquement a la denormalisation API Platform pour * permettre au RoleProcessor de detecter une tentative de modification * du code (garde "code immuable"). Le code reste en pratique fige apres * creation : le processor refuse toute modification via 400. * * @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes. * Hors contexte API Platform, cette methode modifie silencieusement * le code sans aucun garde. */ public function setCode(string $code): static { $this->code = $code; return $this; } /** * Met a jour le libelle affichable du role. Le code reste immuable pour * garantir la stabilite des references cote fixtures et migrations. */ public function setLabel(string $label): static { $this->label = $label; return $this; } /** * Met a jour la description libre du role (champ documentaire). */ public function setDescription(?string $description): static { $this->description = $description; return $this; } /** * Ajoute une permission au role. Idempotent : ajouter deux fois la meme * permission n'entraine pas de doublon dans la collection. */ public function addPermission(Permission $permission): static { if (!$this->permissions->contains($permission)) { $this->permissions->add($permission); } return $this; } /** * Retire une permission du role. Idempotent : retirer une permission * absente est un no-op silencieux. */ public function removePermission(Permission $permission): static { $this->permissions->removeElement($permission); return $this; } /** * Garde domaine : refuse la suppression d'un role marque comme systeme. * La traduction HTTP (403) est faite au niveau application / API Platform. */ public function ensureDeletable(): void { if ($this->isSystem) { throw SystemRoleDeletionException::forRole($this); } } }