diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 4a3d3d6..d0b33f3 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -8,39 +8,10 @@ ## Messages de validation (obligatoire) -**Toute contrainte `#[Assert\*]` portee par une entite metier doit avoir un message FR explicite**, et **`Assert\Length.max` doit refleter le `length` de la colonne ORM**. Pendant logique back de la regle de mapping d'erreur par champ cote front (ERP-101 : `useFormErrors` / `mapViolationsToRecord` affiche sous chaque champ le `message` renvoye par le back). +Toute contrainte `#[Assert\*]` d'une entite metier : **message FR explicite**, et `Assert\Length.max` = `length` de la colonne ORM (coherence 3 niveaux nullable DB <-> NotBlank back <-> required front, ERP-101). RG inter-champs via `#[Assert\Callback]->atPath('')` (mapping inline front, pas toast). Exceptions miroir Length (Bic/Iban/Regex borne) : whitelist `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR`. -Pourquoi : -- Sans `message:` explicite, Symfony renvoie le defaut **anglais** (« This value is not a valid email address. »). La locale FR globale (`default_locale: fr` dans `framework.yaml`) sert de FILET via `validators.fr.xlf`, mais les contraintes metier portent en plus leur message FR pour un controle total. -- Une colonne string bornee **sans `Assert\Length`** echoue au niveau Postgres (500 generique, non rattachee au champ) au lieu d'une 422 propre. Le `max` doit egaler le `length` ORM (anti-derive). - -Pattern par champ scalaire : - -```php -// Email metier -#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] - -// Longueur calee sur la colonne (VARCHAR(120)) -#[ORM\Column(length: 120)] -#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] - -// Obligatoire (aligner nullable DB / NotBlank back / required front) -#[Assert\NotBlank(message: 'Le téléphone est obligatoire.', normalizer: 'trim')] -``` - -Coherence a 3 niveaux pour un champ obligatoire : colonne `nullable` (DB) <-> `Assert\NotBlank` (back) <-> `:required` + asterisque (front ERP-101). Les trois doivent s'accorder. - -Exceptions au miroir `Length` : un format deja borne par `Assert\Bic` / `Assert\Iban` (longueur garantie) ou par un `Assert\Regex` borne (ex. code postal `{4,5}`, couleur hex `#RRGGBB`) — whitelister alors la propriete dans `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR` avec justification. - -Les regles inter-champs (RG metier : exclusivite distributor/broker RG-1.03, billingEmail RG-1.11, etc.) passent par un `#[Assert\Callback]` qui construit la violation avec `->atPath('')` — indispensable pour que le front la mappe en inline plutot qu'en toast. - -### Garde-fou architecture - -`tests/Architecture/EntityConstraintsHaveFrenchMessageTest` scanne reflexivement les entites sous `src/Module/*/Domain/Entity/` et echoue si : -1. une contrainte connue n'a pas de message FR explicite (compare au defaut Symfony) ; -2. une colonne string bornee writable n'a pas de `Assert\Length(max == ORM length)` (hors whitelist). - -Une contrainte non geree par le mapping du test le fait echouer : il faut l'ajouter explicitement (anti faux positif vert). +Garde-fou : `tests/Architecture/EntityConstraintsHaveFrenchMessageTest` (casse `make test`). +→ patterns code + exemples + justification complete : skill `backend-entity-conventions`. ## API Platform (pas de controllers) @@ -51,61 +22,10 @@ Une contrainte non geree par le mapping du test le fait echouer : il faut l'ajou ## Pagination (obligatoire) -**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur. +Toute collection API est paginee (defaut 10, max 50 ; `?pagination=false` = echappatoire selects, `?itemsPerPage=25` borne par le max). Standard global dans `config/packages/api_platform.yaml`. Jamais `paginationEnabled: false` hors whitelist `CollectionsArePaginatedTest::EXCLUDED`. Provider custom : ne jamais retourner un `array` brut sur une `CollectionOperationInterface` (court-circuite Hydra) — wrapper un Paginator (ORM : `ApiPlatform\Doctrine\Orm\Paginator` ; DBAL : `DbalPaginator`) et gerer `?pagination=false` via `$this->pagination->isEnabled(...)`. -### Standard global - -Pose dans `config/packages/api_platform.yaml` (section `defaults:`) et heritee par toutes les ressources : - -| Cle | Valeur | Effet | -|---|---|---| -| `pagination_enabled` | `true` | Pagination Hydra active par defaut. | -| `pagination_items_per_page` | `10` | Taille de page par defaut, aligne sur l'UI `MalioDataTable`. | -| `pagination_maximum_items_per_page` | `50` | Borne dure : `?itemsPerPage=999` → ramene a 50. Anti deep-fetch. | -| `pagination_client_items_per_page` | `true` | Le client peut envoyer `?itemsPerPage=25` (bornee par le max). | -| `pagination_client_enabled` | `true` | Le client peut envoyer `?pagination=false` pour TOUT recuperer (echappatoire selects). | - -### Override par ressource (rare) - -Si une ressource a besoin d'un autre defaut (ex: payload lourd), utiliser les attributs sur l'operation. **JAMAIS `paginationEnabled: false`** sans whitelist explicite dans `tests/Architecture/CollectionsArePaginatedTest::EXCLUDED`. - -```php -new GetCollection( - paginationItemsPerPage: 5, // override taille par defaut - paginationMaximumItemsPerPage: 20, // override borne max -) -``` - -### Selects et autocompletions - -Pour alimenter un `` ou un drawer RBAC (Role, Permission, Site, CategoryType), le front passe : + +```ts +useApi().get('/api/roles?pagination=false') +``` + +Le serveur retourne toute la collection, sans `view`. C'est l'échappatoire prévue par +`pagination_client_enabled: true`. Sur les ressources à forte volumétrie, préférer une saisie assistée +(recherche serveur via `?q=`) — à planifier dans un ticket dédié. + +Les tests fonctionnels qui exercent ce comportement doivent également passer `?pagination=false` +(cf. `CategoryListTest`, `PermissionApiTest`). + +### Providers customs et pagination + +Un provider custom qui retourne un `array` brut sur une `CollectionOperationInterface` +**court-circuite la pagination Hydra** (pas de `totalItems`, pas de `view`). Patterns supportés : + +- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un + `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`. +- **DBAL** : implémenter un paginator local conforme à `PaginatorInterface`. Exemple : `DbalPaginator` + (Core) + `AuditLogProvider`. + +Gérer l'échappatoire `?pagination=false` : + +```php +if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); // tout retourner +} +``` + +### Garde-fou architecture + +`tests/Architecture/CollectionsArePaginatedTest` scanne réflexivement toutes les classes +`#[ApiResource]` sous `src/` et échoue si une `GetCollection` pose `paginationEnabled: false` hors +whitelist `EXCLUDED`. Ajouter une entrée à la whitelist requiert une justification courte + un ticket +Lesstime ouvert. + +--- + +## 3. Libellé i18n du type d'entité auditée (Garde-fou : `AuditableEntitiesHaveI18nLabelTest`) + +**Toute entité `#[Auditable]` doit avoir son libellé FR dans le bloc `audit.entity` de +`frontend/i18n/locales/fr.json`.** C'est la contrepartie i18n de l'attribut : sans elle, le filtre +« Type d'entité » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un +libellé lisible. + +Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts +présents en base) ; dès qu'un module audite une entité, son type y apparaît. Le front +(`formatEntityType`, `audit-log.vue`) construit la clé `audit.entity._` et, faute de +traduction, **retombe silencieusement** sur le type brut. + +Dérivation de la clé (emplacement centralisé + schéma flat — décision ERP-99) : + +| FQCN entité | `entity_type` (back) | Clé i18n (flat) | +|---|---|---| +| `App\Module\Commercial\Domain\Entity\Client` | `commercial.Client` | `commercial_client` | +| `App\Module\Commercial\Domain\Entity\ClientAddress` | `commercial.ClientAddress` | `commercial_clientaddress` | +| `App\Module\Catalog\Domain\Entity\Category` | `catalog.Category` | `catalog_category` | + +Règle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa clé de libellé audit fait partie +de la **définition de fini** d'une entité métier auditée. + +**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entités `#[Auditable]` +et échoue si une seule n'a pas sa clé `audit.entity.*`. Conclusion : créer une entité `#[Auditable]` +sans son libellé i18n casse `make test`. + +--- + +## 4. Timestampable + Blamable (Garde-fou : `EntitiesAreTimestampableBlamableTest`) + +Toute **nouvelle** entité métier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes +`created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes à +ajouter à l'entité : + +```php +use App\Shared\Domain\Contract\BlamableInterface; +use App\Shared\Domain\Contract\TimestampableInterface; +use App\Shared\Domain\Trait\TimestampableBlamableTrait; + +class MyEntity implements TimestampableInterface, BlamableInterface +{ + use TimestampableBlamableTrait; // porte les 4 props + getters/setters + // ... reste métier +} +``` + +- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au + `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` + (libellé « Système » côté front). +- La migration de l'entité doit créer les 4 colonnes (`created_at` / `updated_at` NOT NULL, + `created_by` / `updated_by` nullable `ON DELETE SET NULL`). +- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` échoue si une entité + oublie le pattern. Un référentiel statique justifié (ex: `CategoryType`) doit être explicitement + whitelisté dans la constante `EXCLUDED` avec un commentaire. +- Spec complète : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis + +--- + +## 5. Migrations Doctrine — COMMENT ON COLUMN (Garde-fou : `ColumnsHaveSqlCommentTest`) + +**Toute migration qui crée ou modifie une colonne d'une table métier doit poser un `COMMENT ON COLUMN` +décrivant le champ.** La description est stockée dans `pg_description` et visible dans tous les outils +d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir à lire les annotations PHP. + +**Format de la description** : +- En français +- ≤ 200 caractères +- Sémantique du champ — contraintes / lien RG si pertinent +- Pour les colonnes d'identifiant ou FK, mentionner la cible + +Exemples : + +```php +// Migration : création d'une colonne avec son commentaire dans la même migration +$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL"); +$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'"); + +// Cas FK : préciser la cible +$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'"); + +// Cas booléen : préciser le sens et la valeur par défaut +$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'"); + +// Bonus : décrire la table elle-même +$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'"); +``` + +### Helper Timestampable/Blamable + +Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutées par +`TimestampableBlamableTrait` reçoivent une description **standardisée** via le helper centralisé pour +éviter la duplication. Helper à créer ou appeler : + +```php +// Dans la migration, après avoir ajouté les 4 colonnes : +$this->addStandardTimestampableBlamableComments($schema, 'client'); +``` + +L'implémentation du helper applique : +- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). » +- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). » +- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. » +- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. » + +### Garde-fou architecture + +`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtré sur le +schéma `public` et échoue si **une seule colonne** n'a pas de `col_description`. Seules les tables +système (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de +justification + ticket Lesstime ouvert pour le retrofit) sont tolérées. + +Conclusion : si tu crées une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.