597101262d
Auto Tag Develop / tag (push) Successful in 8s
## Contexte Résout **ERP-107** — pendant back du mapping d'erreur par champ front (ERP-101). Le front (`useFormErrors` / `mapViolationsToRecord`) affiche sous chaque champ le `message` renvoyé par le back. Ce ticket garantit que ces messages existent, sont en FR et rattachés au bon champ. ## Changements - **Messages FR explicites** sur toutes les contraintes `#[Assert\*]` des entités métier : `Client`, `ClientContact`, `ClientAddress`, `ClientRib`, `Category`, `Role`, `User` (Email, NotBlank, Length, Bic, Iban, PositiveOrZero, Count…). - **Contraintes `Assert\Length` manquantes ajoutées**, calées sur le `length` de la colonne ORM (téléphones `VARCHAR(20)`, `siren`, `nTva`, `accountNumber`, `username`…). Évite une erreur Postgres 500 non rattachée au champ → 422 propre. - **Locale FR globale** (`symfony/translation` + `default_locale: fr`) comme filet pour les messages natifs Symfony non surchargés. - **Garde-fou** `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` : échoue si une contrainte n'a pas de message FR explicite (comparaison au défaut Symfony) ou si `Assert\Length.max` diverge du `length` ORM. Whitelist justifiée pour les formats auto-bornés (Bic/Iban/Regex CP/couleur hex). - **Test fonctionnel** du JSON 422 réel : message FR + `propertyPath` consommable par le front. - **Convention documentée** dans `.claude/rules/backend.md`. ## Décisions - Stratégie retenue : message FR **explicite sur toutes** les contraintes + locale FR en filet (les deux leviers du ticket). - Garde-fou `Length == ORM length` : **test bloquant** (anti-dérive). - RG-1.03 (distributor/broker) : pas de `Assert\Callback` ajouté — le `ClientProcessor` gère **déjà** l'exclusivité (422 + `propertyPath`). Pas de doublon. ## Hors périmètre / à suivre - **Alignement `nullable`(DB) / `NotBlank`(back) / `required`(front)** : les champs obligatoires existants ont été confirmés, mais aucun changement de nullabilité DB n'a été fait sans arbitrage métier. À recroiser avec les astérisques front (ERP-101 / PR #58) si divergence constatée. ## Vérifications - `make test` : **469 tests verts** (1793 assertions), 0 échec/erreur. - `php-cs-fixer` : 0 fichier à corriger. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #59 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
238 lines
8.2 KiB
PHP
238 lines
8.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Module\Core\Domain\Entity;
|
|
|
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
|
use ApiPlatform\Metadata\ApiFilter;
|
|
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\Exception\SystemRoleDeletionException;
|
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
|
use App\Shared\Domain\Attribute\Auditable;
|
|
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\Serializer\Attribute\SerializedName;
|
|
use Symfony\Component\Validator\Constraints as Assert;
|
|
|
|
/**
|
|
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
|
|
*
|
|
* Un role peut etre "systeme" (cree et protege par la plateforme) ou
|
|
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
|
|
* peuvent etre supprimes.
|
|
*/
|
|
#[ApiResource(
|
|
operations: [
|
|
new GetCollection(
|
|
normalizationContext: ['groups' => ['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`')]
|
|
#[Auditable]
|
|
#[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(message: 'Le code du rôle est obligatoire.', normalizer: 'trim')]
|
|
#[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit être en snake_case et commencer par une lettre minuscule.')]
|
|
#[Assert\Length(max: 100, maxMessage: 'Le code ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
|
private string $code;
|
|
|
|
#[ORM\Column(length: 255)]
|
|
#[Groups(['role:read', 'role:write'])]
|
|
#[Assert\NotBlank(message: 'Le libellé du rôle est obligatoire.', normalizer: 'trim')]
|
|
#[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
|
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<int, Permission> */
|
|
// 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<int, Permission> */
|
|
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);
|
|
}
|
|
}
|
|
}
|