diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 7534f0c..e21a688 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -74,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro ## 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. diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml index c161627..5b6b29c 100644 --- a/.gitea/workflows/pull-request.yml +++ b/.gitea/workflows/pull-request.yml @@ -77,6 +77,9 @@ jobs: php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction php bin/console doctrine:migrations:migrate --env=test --no-interaction php bin/console doctrine:schema:update --env=test --force --no-interaction + # Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) : + # schema:update drop les commentaires des tables managees par l'ORM. + php bin/console app:apply-column-comments --env=test --no-interaction php bin/console doctrine:fixtures:load --env=test --no-interaction php bin/console app:sync-permissions --env=test --no-interaction diff --git a/CLAUDE.md b/CLAUDE.md index 137720e..df64645 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md 9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation. 10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement. 11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison. +12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine. ## Conventions @.claude/rules/architecture.md diff --git a/makefile b/makefile index bf774a7..71ab933 100644 --- a/makefile +++ b/makefile @@ -207,10 +207,16 @@ migration-migrate: # recree par dbal:run-sql pour que les tests RG-1.07 (unicite # case-insensitive) voient bien la contrainte SQL. Sans ce restore, les # POST doublons remontent 201 au lieu de 409. +# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT +# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte +# pas d'attribut options['comment']). On rejoue le catalogue partage +# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par +# le test architecture ColumnsHaveSqlCommentTest (ERP-67). test-db-setup: $(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists $(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction $(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force + $(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" diff --git a/migrations/Version20260528120000.php b/migrations/Version20260528120000.php new file mode 100644 index 0000000..6e38e14 --- /dev/null +++ b/migrations/Version20260528120000.php @@ -0,0 +1,66 @@ +addSql($sql); + } + } + + public function down(Schema $schema): void + { + foreach (ColumnCommentsCatalog::comments() as $table => $entries) { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + foreach ($entries as $column => $_) { + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable)); + + continue; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS NULL', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + )); + } + } + } +} diff --git a/src/Module/Core/Infrastructure/Console/ApplyColumnCommentsCommand.php b/src/Module/Core/Infrastructure/Console/ApplyColumnCommentsCommand.php new file mode 100644 index 0000000..bd8dada --- /dev/null +++ b/src/Module/Core/Infrastructure/Console/ApplyColumnCommentsCommand.php @@ -0,0 +1,41 @@ +connection->executeStatement($sql); + } + + $io->success(sprintf('%d COMMENT ON statements appliques.', count($statements))); + + return Command::SUCCESS; + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php new file mode 100644 index 0000000..5312165 --- /dev/null +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -0,0 +1,188 @@ +> + */ + public static function comments(): array + { + return [ + 'audit_log' => [ + '_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.", + 'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).", + 'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.", + 'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).", + 'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.", + 'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).', + 'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.", + 'performed_at' => "Horodatage UTC de l'action auditee.", + 'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.", + 'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.", + ], + + 'category' => [ + '_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.', + 'id' => 'Identifiant interne auto-incremente.', + 'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).', + 'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).', + 'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.', + ] + self::timestampableBlamableComments(), + + 'category_type' => [ + '_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.', + 'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).', + ], + + 'permission' => [ + '_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.', + 'label' => 'Libelle affichable de la permission (FR).', + 'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).', + 'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.", + ], + + 'role' => [ + '_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.', + 'label' => 'Libelle affichable du role (FR).', + 'description' => 'Description longue du role (optionnelle).', + 'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.", + ], + + 'role_permission' => [ + '_table' => 'Table de jointure roles <-> permissions (ManyToMany).', + 'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.', + 'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.', + ], + + 'site' => [ + '_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.', + 'id' => 'Identifiant interne auto-incremente.', + 'name' => 'Nom du site (≤ 100 caracteres).', + 'city' => 'Ville du site (≤ 100 caracteres).', + 'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.', + 'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.", + 'street' => "Numero et voie de l'adresse (≤ 200 caracteres).", + 'complement' => "Complement d'adresse (etage, batiment...) — optionnel.", + 'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.', + 'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.', + ], + + 'user' => [ + '_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.', + 'id' => 'Identifiant interne auto-incremente.', + 'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.', + 'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].', + 'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).', + 'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.', + 'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.", + ], + + 'user_permission' => [ + '_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).', + 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.', + 'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.', + ], + + 'user_role' => [ + '_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).', + 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.', + 'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.', + ], + + 'user_site' => [ + '_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).', + 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.', + 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.', + ], + ]; + } + + /** + * Descriptions standardisees pour les 4 colonnes du pattern + * Timestampable/Blamable (`TimestampableBlamableTrait`). + * + * @return array + */ + public static function timestampableBlamableComments(): array + { + return [ + 'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.', + 'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.', + 'created_by' => "ID de l'utilisateur ayant cree la ligne — null 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 hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.", + ]; + } + + /** + * Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en + * dollar-quoting Postgres `$_$`) a partir du catalogue. + * + * @return list + */ + public static function toSqlStatements(): array + { + $statements = []; + foreach (self::comments() as $table => $entries) { + $quotedTable = self::quoteIdent($table); + foreach ($entries as $column => $description) { + if ('_table' === $column) { + $statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description); + + continue; + } + + $statements[] = sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + self::quoteIdent($column), + $description, + ); + } + } + + return $statements; + } + + /** + * Quote un identifiant SQL avec des guillemets doubles. Necessaire pour + * la table `user` (mot reserve PG) ; applique a tous par coherence. + */ + private static function quoteIdent(string $name): string + { + return '"'.str_replace('"', '""', $name).'"'; + } +} diff --git a/tests/Architecture/ColumnsHaveSqlCommentTest.php b/tests/Architecture/ColumnsHaveSqlCommentTest.php new file mode 100644 index 0000000..35902ee --- /dev/null +++ b/tests/Architecture/ColumnsHaveSqlCommentTest.php @@ -0,0 +1,114 @@ + + */ + private const EXCLUDED_TEST_FIXTURES = [ + // tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module + // Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update + // sur la DB de test uniquement. + 'fake_site_aware_entity', + ]; + + /** + * Whitelist metier — DOIT rester vide ou justifiee. + * + * Chaque entree doit comporter (1) un commentaire expliquant pourquoi la + * table n'est pas encore documentee et (2) la reference d'un ticket + * Lesstime ouvert pour le retrofit. + * + * @var list + */ + private const EXCLUDED_TABLES = []; + + public function testAllPublicColumnsHaveASqlComment(): void + { + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.default_connection'); + + $excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES]; + + $rows = $conn->fetchAllAssociative( + <<<'SQL' + SELECT c.table_name, c.column_name + FROM information_schema.columns c + WHERE c.table_schema = 'public' + AND c.table_name NOT IN (:excluded) + AND col_description( + (c.table_schema || '.' || c.table_name)::regclass, + c.ordinal_position + ) IS NULL + ORDER BY c.table_name, c.ordinal_position + SQL, + ['excluded' => $excluded], + ['excluded' => ArrayParameterType::STRING], + ); + + if ([] !== $rows) { + $missing = array_map( + static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']), + $rows, + ); + + self::fail(sprintf( + "%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s", + count($missing), + implode("\n - ", $missing), + )); + } + + // Garde : si la requete ne renvoie rien et qu'aucune table publique + // n'existe (sauf doctrine_migration_versions), le test deviendrait un + // faux positif vert. On verifie qu'il y a bien des tables a auditer. + $tableCount = (int) $conn->fetchOne( + <<<'SQL' + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name NOT IN (:excluded) + SQL, + ['excluded' => $excluded], + ['excluded' => ArrayParameterType::STRING], + ); + + self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.'); + } +}