9202d2ee6f
Audit et complete les messages des contraintes #[Assert\*] des entites metier (pendant back du mapping d'erreur par champ front ERP-101) : - Message FR explicite sur toutes les contraintes (Email, NotBlank, Length, Bic, Iban, PositiveOrZero, Count...) des entites Client, ClientContact, ClientAddress, ClientRib, Category, Role, User. - Ajout des Assert\Length manquantes calees sur le length de la colonne ORM (telephones VARCHAR(20), siren, nTva, accountNumber, username...) : evite une erreur Postgres 500 non rattachee au champ au profit d'une 422 propre. - Locale FR globale (symfony/translation + default_locale: fr) comme filet pour les messages natifs non surcharges. - Garde-fou tests/Architecture/EntityConstraintsHaveFrenchMessageTest : echoue si une contrainte n'a pas de message FR explicite ou si Assert\Length.max diverge du length ORM (whitelist justifiee pour les formats Bic/Iban/Regex). - Test fonctionnel du JSON 422 reel (message FR + propertyPath consommable par useFormErrors cote front). - Convention documentee dans .claude/rules/backend.md. Tests : 469 verts (1793 assertions).
239 lines
14 KiB
Markdown
239 lines
14 KiB
Markdown
# Backend — Regles PHP / Symfony / API Platform
|
|
|
|
## Structure de fichier
|
|
|
|
- Toujours `declare(strict_types=1);` en tete de tout fichier PHP
|
|
- PHP CS Fixer : regles Symfony + PSR-12 + strict types (commande : `make php-cs-fixer-allow-risky`)
|
|
- Commentaires (docblock, inline, bloc) **en francais** ; code (classes, methodes, variables) en anglais
|
|
|
|
## 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).
|
|
|
|
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('<champ>')` — 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).
|
|
|
|
## API Platform (pas de controllers)
|
|
|
|
- Toujours utiliser `#[ApiResource]` + Providers + Processors — pas de controllers Symfony classiques
|
|
- Routes prefixees `/api` (via `config/routes/api_platform.yaml`)
|
|
- Le login `/login_check` est **hors** prefix `/api` (nginx reecrit `REQUEST_URI` vers `/login_check`)
|
|
- **Exception** : si tu dois creer un controller custom sous `/api/`, mettre `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
|
|
|
|
## Pagination (obligatoire)
|
|
|
|
**Regle** : toute collection API DOIT etre paginee. Aucun retour de collection complete cote serveur.
|
|
|
|
### 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 `<select>` 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'echappatoire prevue par `pagination_client_enabled: true`. Sur les ressources a forte volumetrie, preferer une saisie assistee (recherche serveur via `?q=`) — a planifier dans un ticket dedie.
|
|
|
|
Les tests fonctionnels qui exercent ce comportement doivent egalement 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 supportes :
|
|
|
|
- **ORM** : injecter `ApiPlatform\State\Pagination\Pagination`, wrap un `Doctrine\ORM\Tools\Pagination\Paginator` dans `ApiPlatform\Doctrine\Orm\Paginator`. Exemple : `CategoryProvider`.
|
|
- **DBAL** : implementer un paginator local conforme a `PaginatorInterface`. Exemple : `DbalPaginator` (Core) + `AuditLogProvider`.
|
|
|
|
Gerer l'echappatoire `?pagination=false` :
|
|
|
|
```php
|
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
|
return $qb->getQuery()->getResult(); // tout retourner
|
|
}
|
|
```
|
|
|
|
### Garde-fou architecture
|
|
|
|
`tests/Architecture/CollectionsArePaginatedTest` scanne reflexivement toutes les classes `#[ApiResource]` sous `src/` et echoue si une `GetCollection` pose `paginationEnabled: false` hors whitelist `EXCLUDED`. Ajouter une entree a la whitelist requiert une justification courte + un ticket Lesstime ouvert.
|
|
|
|
## Repositories
|
|
|
|
- Interface : `*RepositoryInterface` dans `Domain/Repository/`
|
|
- Implementation Doctrine : `Doctrine*Repository` dans `Infrastructure/Doctrine/`
|
|
- Le domaine garde les attributs ORM (approche pragmatique)
|
|
|
|
## RBAC (permissions)
|
|
|
|
Format obligatoire : `module.resource[.subresource].action` en snake_case.
|
|
- Exemples : `core.users.view`, `commercial.clients.contacts.edit`, `core.audit_log.view`
|
|
- Declarees via la methode statique `permissions()` des `*Module.php`
|
|
- Synchronisation : `app:sync-permissions`
|
|
- Verification API Platform : `is_granted('module.resource.action')`
|
|
- Verification front : `usePermissions()`
|
|
|
|
## Roles
|
|
|
|
- Hierarchie dans `config/packages/security.yaml` : `ROLE_ADMIN`, `ROLE_USER`
|
|
- Le role ne remplace pas la permission RBAC — deux niveaux complementaires
|
|
|
|
## Audit (obligatoire)
|
|
|
|
- Toute entite metier (nouvelle ou existante) : `#[Auditable]` (de `Shared/Domain/Attribute/`)
|
|
- Champs sensibles (password, token, secret) : `#[AuditIgnore]`
|
|
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
|
|
- Spec complete : @doc/audit-log.md
|
|
|
|
### Libelle i18n du type d'entite (obligatoire avec `#[Auditable]`)
|
|
|
|
**Toute entite `#[Auditable]` doit avoir son libelle 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'entite » de l'audit-log affiche le type technique brut (ex: `commercial.Client`) au lieu d'un libelle lisible.
|
|
|
|
Pourquoi : le filtre est dynamique (`GET /audit-log-entity-types` renvoie les `entity_type` distincts presents en base) ; des qu'un module audite une entite, son type y apparait. Le front (`formatEntityType`, `audit-log.vue`) construit la cle `audit.entity.<module>_<entity>` et, faute de traduction, **retombe silencieusement** sur le type brut.
|
|
|
|
Derivation de la cle (emplacement centralise + schema flat — decision ERP-99) :
|
|
|
|
| FQCN entite | `entity_type` (back) | Cle 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` |
|
|
|
|
Regle : `strtolower(module)` + `_` + `strtolower(Entity)`. Ajouter sa cle de libelle audit fait partie de la **definition de fini** d'une entite metier auditee.
|
|
|
|
**Garde-fou** : `tests/Architecture/AuditableEntitiesHaveI18nLabelTest` scanne les entites `#[Auditable]` et echoue si une seule n'a pas sa cle `audit.entity.*`. Conclusion : creer une entite `#[Auditable]` sans son libelle i18n casse `make test`.
|
|
|
|
## Timestampable + Blamable (obligatoire pour entites metier)
|
|
|
|
Toute **nouvelle** entite metier sous `src/Module/*/Domain/Entity/` doit porter les 4 colonnes `created_at` / `updated_at` / `created_by` / `updated_by`, remplies automatiquement. Trois lignes a ajouter a l'entite :
|
|
|
|
```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 metier
|
|
}
|
|
```
|
|
|
|
- Le `TimestampableBlamableSubscriber` (`Shared/Infrastructure/Doctrine/`) remplit les colonnes au `prePersist` / `preUpdate`. Hors contexte HTTP (CLI, cron, migration), le blame reste `null` (libelle « Systeme » cote front).
|
|
- La migration de l'entite doit creer les 4 colonnes (`created_at` / `updated_at` NOT NULL, `created_by` / `updated_by` nullable `ON DELETE SET NULL`).
|
|
- **Garde-fou CI** : `tests/Architecture/EntitiesAreTimestampableBlamableTest` echoue si une entite oublie le pattern. Un referentiel statique justifie (ex: `CategoryType`) doit etre explicitement whiteliste dans la constante `EXCLUDED` avec un commentaire.
|
|
- Spec complete : @docs/specs/M0-categories/spec-back.md § 2.8 + § 2.8.bis
|
|
|
|
## Serialization
|
|
|
|
Pour embarquer une relation dans le JSON (au lieu d'un IRI Hydra), ajouter le groupe du parent sur les proprietes de l'entite cible.
|
|
|
|
Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le groupe `user:read`, annoter `Profile.$firstName` avec `#[Groups(['user:read'])]`.
|
|
|
|
## Upload de fichiers
|
|
|
|
- Valider cote serveur avec `$file->getMimeType()` — **jamais** `getClientMimeType()` (spoofable par le client)
|
|
|
|
## PostgreSQL
|
|
|
|
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO)
|
|
|
|
## Migrations Doctrine
|
|
|
|
### Documentation SQL obligatoire (`COMMENT ON COLUMN`)
|
|
|
|
**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP.
|
|
|
|
**Format de la description** :
|
|
- En francais
|
|
- ≤ 200 caracteres
|
|
- Semantique du champ — contraintes / lien RG si pertinent
|
|
- Pour les colonnes d'identifiant ou FK, mentionner la cible
|
|
|
|
Exemples :
|
|
|
|
```php
|
|
// Migration : creation d'une colonne avec son commentaire dans la meme 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 : preciser 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 booleen : preciser le sens et la valeur par defaut
|
|
$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'");
|
|
|
|
// Bonus : decrire la table elle-meme
|
|
$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` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler :
|
|
|
|
```php
|
|
// Dans la migration, apres avoir ajoute les 4 colonnes :
|
|
$this->addStandardTimestampableBlamableComments($schema, 'client');
|
|
```
|
|
|
|
L'implementation 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` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees.
|
|
|
|
Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI.
|