Compare commits

..

22 Commits

Author SHA1 Message Date
gitea-actions 0ed131ce57 chore: bump version to v0.1.53
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 16s
2026-05-29 09:44:34 +00:00
matthieu a948eed9b6 [ERP-67] Documenter toutes les colonnes BDD via COMMENT ON COLUMN + garde-fou (#24)
Auto Tag Develop / tag (push) Successful in 7s
Ticket Lesstime : ERP-67 — `[Convention SQL / Backend / L]`

## Objectif

Documenter toutes les colonnes BDD via `COMMENT ON COLUMN` (visible dans DBeaver / DataGrip / pgAdmin sans lire le code Doctrine) et verrouiller la convention par un garde-fou de test architecture.

## Changements

### Convention (CLAUDE.md + rules)

- `CLAUDE.md` regle ABSOLUE n°12 : toute migration creant ou modifiant une colonne doit poser un `COMMENT ON COLUMN` (FR, ≤ 200 caracteres).
- `.claude/rules/backend.md` § Migrations Doctrine : exemples + helper standardise pour les 4 colonnes du `TimestampableBlamableTrait`.

### Garde-fou architecture

- `tests/Architecture/ColumnsHaveSqlCommentTest` : echoue si une colonne `public` n'a pas de `col_description` (hors `doctrine_migration_versions` et `fake_site_aware_entity` fixture de test).
- Whitelist metier `EXCLUDED_TABLES` volontairement vide.

### Retrofit des tables existantes

- Migration `Version20260528120000` : 64 `COMMENT ON TABLE/COLUMN` sur les 11 tables metier (audit_log, category, category_type, permission, role, role_permission, site, user, user_permission, user_role, user_site).
- Source unique de verite : `src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php`.
- Commande `app:apply-column-comments` (Module/Core/Infrastructure/Console) : rejoue le catalogue apres `doctrine:schema:update --force` (sinon l'ORM drop les commentaires absents du mapping PHP). Branchee dans `makefile test-db-setup` et `.gitea/workflows/pull-request.yml`.

## Validation

- `make db-reset` puis `make test` : 312 tests verts, 0 regression.
- `make php-cs-fixer-allow-risky` : 0 fix.
- Couverture : 53/53 colonnes documentees sur `starseed` et `starseed_test`.

## Test plan

- [ ] `make db-reset` passe sans erreur.
- [ ] `make test` passe ; `ColumnsHaveSqlCommentTest` vert sur DB de test.
- [ ] Verifier dans DBeaver / pgAdmin que les commentaires apparaissent sur les colonnes de `category`, `user`, `audit_log`.
- [ ] Verifier que le workflow CI Gitea (`pull-request.yml`) passe.

## A noter pour la suite

La convention `options: ['comment' => '...']` sur chaque `#[ORM\Column]` reste recommandee pour les nouvelles entites — Doctrine genere alors automatiquement le `COMMENT ON COLUMN` dans la migration et `schema:update` le preserve sans avoir a rejouer le catalogue. A discuter si on veut en faire une regle forte.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #24
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-29 09:41:29 +00:00
gitea-actions fc78f434d1 chore: bump version to v0.1.52
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:23:47 +00:00
tristan 53e19d61ac [ERP-51] Écrire les tests Vitest des composables Catalog (#26)
Auto Tag Develop / tag (push) Successful in 7s
## Résumé

Couvre les deux composables Catalog extraits du refactor ERP-50 avec **42 tests Vitest unitaires** (happy-dom, sans dépendance backend).

- 14 tests sur \`useCategoriesAdmin\` (fetchAll/fetchTypes, includeDeleted, loading, error, reset, singleton)
- 28 tests sur \`useCategoryForm\` (validation RG-1.02/1.04/1.05 + trim, POST/PATCH/DELETE, mapping 409 RG-1.07 + 422 violations, isDirty, loadFrom, reset, isolation)

Mocks via \`vi.stubGlobal\` (useApi / useI18n / useToast) et \`vi.mock\` (\`~/shared/stores/auth\` pour neutraliser l'auto-enregistrement \`onAuthSessionCleared\`). La suite tourne en **~1.2s**.

Ticket Lesstime : #51

## Tests automatisés

- \`make nuxt-test\` ✓ 85 tests (dont 42 nouveaux), 0 échec, 1.2s

## Reviewer

@matthieu

## À tester en local

- [ ] \`make nuxt-test\` passe
- [ ] Mock \`useApi\` reste stable si le pattern d'auto-import Nuxt évolue
- [ ] Couverture jugée suffisante des cas back miroir

Reviewed-on: #26
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:23:41 +00:00
gitea-actions ece8146c03 chore: bump version to v0.1.51
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-05-29 09:18:36 +00:00
tristan 58589e93d0 [ERP-50] Implémenter les composables useCategoriesAdmin et useCategoryForm (#25)
Auto Tag Develop / tag (push) Successful in 6s
Lien Lesstime : #50

## Résumé
Refacto : extraction de la logique fetch/CRUD inline de la page categories (ERP-49) vers deux composables dédiés, conformément au pattern Starseed (useSidebar / useModules).

- **useCategoriesAdmin** : singleton state (`categories` + `types` + `loading` + `error`). Pré-chargement des types au mount de la page (au lieu d'un fetch par ouverture du drawer). Reset au logout via `onAuthSessionCleared` + appel explicite dans `logout.vue`.
- **useCategoryForm** : state local par form (pas singleton, contrairement à `useCategoriesAdmin`). Valide côté client en miroir des RG back (RG-1.02 / RG-1.04 / RG-1.05), mappe les erreurs 409 (RG-1.07 doublon) et 422 (violations API Platform) sur les bons champs. `submitCreate` / `submitUpdate` / `submitDelete` renvoient la ressource ou `null` pour découpler la décision de fermeture du drawer.

La page et le drawer deviennent purement présentationnels — aucune régression UX attendue (mêmes validations, mêmes toasts, même bascule view → edit via `isDirty` exposé par le composable).

## Décisions
- `useCategoriesAdmin` porte aussi les types (`fetchTypes`), pas seulement `categories` — sinon le drawer continuerait à fetcher tout seul et la refacto n'aurait rien centralisé.
- `buildCreatePayload` retourne `Record<string, unknown>` (pas `CategoryCreateInput`) car la signature `useApi.post(body: AnyObject)` n'accepte pas les types stricts (variance TS).
- Reset au logout : double mécanisme conservé (auto via `onAuthSessionCleared` pour 401, explicite dans `logout.vue` pour logout volontaire — pattern existant Starseed).

## Tests
- `npx nuxi typecheck` ✓ 0 erreur nouvelle (1 erreur pré-existante sur `modules/catalog/nuxt.config.ts` héritée d'ERP-49)
- `make nuxt-test` ✓ 43/43, 0 régression
- PHPUnit ✓ 311/311 (pre-commit)
- Manuel navigateur : à valider (cahier de test consigné dans Lesstime #50)

## ⚠ Note d'intégration
La branche contient encore les 3 commits ERP-49 (`4046910`, `216f388`, `934a12b`) car elle a été créée depuis la branche ERP-49 avant son merge sur develop. Selon l'ordre de merge : soit ERP-49 est mergée d'abord (cette MR ne contiendra plus que le commit ERP-50 après rebase auto), soit cette MR embarque tout l'historique catalog.

Reviewed-on: #25
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 09:18:29 +00:00
gitea-actions e0d59962d6 chore: bump version to v0.1.50
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-29 08:59:54 +00:00
tristan 3ce40a707f [ERP-49] Créer la page Gestion des catégories (datatable + drawer) (#22)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Ticket Lesstime : [#49](https://lesstime.malio.fr/tasks/460) — premier ticket front du M0 (Gestion des catégories).
Suit la chaîne back ERP-43..48 mergée sur develop.

## Contenu first draft (Claude Code)
- Page Nuxt `/admin/categories` (`MalioDataTable` + bouton `+ Ajouter`)
- Composant `<CategoryDrawer>` : modes création / consultation / édition, transition auto view → edit à la première modification, validation client miroir RG-1.02 (name requis) / RG-1.04 (longueur 2-120) / RG-1.05 (type requis), mapping erreurs 409 (doublon) et 422 (violations)
- Composant `<CategoryDeleteModal>` : confirmation suppression (soft delete RG-1.12)
- Types TS `Category`, `CategoryType`, `User`
- i18n `admin.categories.*` ajouté dans `fr.json`
- Fix latent en passant : ajout de `'categories'` à `AdminLinkSlug` du Page Object e2e (oublié lors d'ERP-47 quand l'item sidebar a été ajouté)

## Décisions marquantes
- Logique `fetch` inline dans `categories.vue` (sera extraite en composables `useCategoriesAdmin` + `useCategoryForm` au ticket ERP-50 / 0.8)
- Drawer dans composant séparé pour réutilisabilité
- Aucun état de tableau persisté dans l'URL (règle ABSOLUE n°6)
- Tous les composants formulaires sont `Malio*` (`MalioDataTable`, `MalioInputText`, `MalioSelect`, `MalioButton`, `MalioDrawer`)

## Polish à venir (Tristan)
Tristan testera en navigateur et peaufinera : UX, classes Tailwind, animations, icônes, wording de toasts.
Les commits de polish suivront sur la même branche.

## Tests
- `npx nuxi typecheck` : net 0 nouvelle erreur (mêmes erreurs pré-existantes que sur `develop`, infrastructure auto-import) + 1 latente corrigée (AdminLinkSlug)
- `make nuxt-test` : 43/43 passent (0 régression)
- Tests manuels navigateur : voir cahier de test du ticket Lesstime #49

## Note pre-commit hook
Le hook a remonté un échec PHPUnit pré-existant sur `develop` (`CategoryDeleteTest::testPatchOnSoftDeletedReturns404` → 401 au lieu de 404, JWT non initialisé en test runner). Aucun PHP touché dans cette MR. Commit avec `--no-verify` autorisé par Tristan.

## Reviewer suggéré
Matthieu (back ↔ front + permissions).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #22
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-29 08:59:47 +00:00
gitea-actions 9613857650 chore: bump version to v0.1.49
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m8s
2026-05-28 12:29:43 +00:00
tristan 2a0918bbfe [#ERP-42] Mettre à jour la lib Malio UI (#16)
Auto Tag Develop / tag (push) Successful in 9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-28 12:27:33 +00:00
gitea-actions 8e31e1759c chore: bump version to v0.1.48
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-05-28 10:25:11 +00:00
matthieu 4824690923 [ERP-48] Écrire les tests PHPUnit RG-1.01 à RG-1.17 (#20)
Auto Tag Develop / tag (push) Successful in 9s
## Mode stacked PR — DERNIER ticket back du M0

**Cible : `feature/ERP-47-declarer-module-catalog-rbac`** (PAS develop).
Quand la MR ERP-47 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Résumé

Suite PHPUnit complète qui mappe chaque RG (1.01 → 1.17) de la spec M0 Catalog vers un ou plusieurs tests ciblés. **63 nouveaux tests** dans 9 classes sous `tests/Module/Catalog/Api/`.

## Cahier de test

| RG | Test(s) |
|---|---|
| **RG-1.01** | `CategoryPermissionsTest::testPersonaWithoutCatalogPermissionGets403*` (4 personas × 4 verbes) + `testAnonymousGets401*` + `testAdminGets{200,201,204}*` + `testUserWithViewPermissionGets200*` |
| **RG-1.02** | `CategoryValidationTest::testNameRequiredReturns422` + `testNameEmptyStringReturns422` + `testNameWhitespaceOnlyReturns422` |
| **RG-1.03** | `CategoryValidationTest::testNameIsTrimmedOnCreate` |
| **RG-1.04** | `CategoryValidationTest::testNameTooShortReturns422` + `testNameTooLongReturns422` + `testNameAtMaxLengthIs201` |
| **RG-1.05** | `CategoryValidationTest::testCategoryTypeRequiredReturns422` + `testCategoryTypeNullIsRejected` |
| **RG-1.06** | `CategoryValidationTest::testCategoryTypeMustExistReturns4xx` |
| **RG-1.07** | `CategoryUniqueTest::testDuplicateNameSameTypeReturns409` + `testDuplicateNameCaseInsensitiveReturns409` + `testSameNameDifferentTypeAllowed` + `testRecreateAfterSoftDeleteAllowed` |
| **RG-1.08** | `CategoryListTest::testListExcludesSoftDeletedByDefault` |
| **RG-1.09** | `CategoryListTest::testIncludeDeletedFlagSurfacesSoftDeleted` |
| **RG-1.10** | `CategoryListTest::testDefaultSortIsNameAsc` |
| **RG-1.11** | `CategoryGetTest::testGetSoftDeletedReturns404` + `testGetSoftDeletedWithFlagReturns200` + `testGetNonExistentReturns404` + `testGetActiveCategoryReturns200` |
| **RG-1.12** | `CategoryDeleteTest::testDeleteReturns204AndPersistsSoftDelete` |
| **RG-1.13** | `CategoryDeleteTest::testPatchCannotSetDeletedAt` |
| **RG-1.11 étendue (404 sur soft-deleted)** | `CategoryDeleteTest::testPatchOnSoftDeletedReturns404` + `testDeleteOnSoftDeletedReturns404` |
| **Audit** | `CategoryAuditTest::testAuditLogOnCreate` + `testAuditLogOnUpdate` + `testAuditLogOnSoftDelete` + `testAuditLogPerformerCarriesAuthenticatedUsername` |
| **RG-1.15** | `CategoryTimestampableBlamableTest::testCreatedByAdminOnPost` + `testCreatedByNullInConsoleContext` |
| **RG-1.16** | `CategoryTimestampableBlamableTest::testPatchUpdatesUpdatedFieldsOnly` + `testSoftDeleteAlsoUpdatesUpdatedFields` |
| **RG-1.17** | `EntitiesAreTimestampableBlamableTest::testAllBusinessEntitiesImplementBothInterfaces` (déjà livré ERP-52, reste vert avec `Category` détectée Timestampable/Blamable et `CategoryType` whitelistée) |

## Side fixes révélés par la suite

### 1. `Category.php` — `normalizer: 'trim'` sur Assert\NotBlank + Length

Avant le fix, POST `{name: "   "}` retournait **201** au lieu de **422** : le Processor trim après validation, mais NotBlank ne fait pas de trim natif. La RG-1.02 (whitespace-only → 422) combinée à la RG-1.03 (trim serveur) exige le `normalizer: 'trim'`. 1 ligne, aligne le contrat sans réordonnancer Validate/Process.

### 2. `makefile` — recréer l'index partiel `uq_category_name_type_active` après `schema:update`

`doctrine:schema:update --env=test --force` drop systématiquement l'index partiel `uq_category_name_type_active` (l'ORM ne sait pas exprimer un index fonctionnel + partiel via attribut Doctrine, donc le voit comme orphelin). Conséquence : POST doublon `(name, type)` retournait **201** au lieu de **409** (la `UniqueConstraintViolation` ne se déclenche plus). Fix : `dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS ..."` ajouté en fin de `test-db-setup`. Approche chirurgicale validée avec Matthieu, ne touche pas à `fake_site_aware_entity` (dépend de schema:update pour exister avant le purger fixtures:load).

## Helpers livrés

`tests/Module/Catalog/Api/AbstractCatalogApiTestCase.php` :
- Factories : `createCategory()`, `createCategoryType()`
- Auth : `createAdminClient()`, `createManageClient()`, `createViewClient()`, `createPersonaClient(string \$label)` (4 personas MALIO sans permission catalog)
- Cleanup : purge complète Category + CategoryType (aucune fixture au M0) + users/roles `test_*`

## Vérifications

-  `make php-cs-fixer-allow-risky` (auto-applied via pre-commit)
-  `make db-reset` (index partiel restauré, vérifié `\d category`)
-  `make test` → **311 tests, 1071 assertions, 0 failure, 0 risky**
  (248 existants + 63 nouveaux, dont 6 deprecations + 6 notices héritées des tests Core RBAC pré-existants — pas de régression introduite par ce ticket)

## Suite

DERNIER ticket back du M0. ERP-52, ERP-43, ERP-44, ERP-45, ERP-46, ERP-47, ERP-48 sont tous en review.
Quand la MR ERP-47 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop. Tristan reviewe la stack en série.

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #20
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:48:16 +00:00
matthieu fceb1e0e83 [ERP-47] Déclarer le module Catalog et synchroniser RBAC (#19)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Ticket Lesstime #47 — M0 position 0.5. Wire le nouveau module **Catalog** dans Starseed et synchronise les 3 sources RBAC (sidebar + personas E2E + seed back). Couvre **RG-1.01** (Admin uniquement) côté infra.

Spec : [`docs/specs/M0-categories/spec-back.md` § 5.1 + § 5.3](https://gitea.malio.fr/MALIO-DEV/Starseed/src/branch/feature/M0-spec-categories/docs/specs/M0-categories/spec-back.md).

> ⚠ **Mode stacked PR** — cible `feature/ERP-46-exposer-category-type-lecture-seule`, **PAS** `develop`. Quand la MR ERP-46 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Modifications (6 fichiers — règle ABSOLUE Starseed n°8 : les 3 sources RBAC bougent ENSEMBLE)

| Fichier | Rôle |
|---|---|
| `src/Module/Catalog/CatalogModule.php` (nouveau) | Déclaration du module : `ID=catalog`, `LABEL=Catalogue`, `REQUIRED=true`, 2 permissions (`view` + `manage`) |
| `config/modules.php` | Wire `CatalogModule::class` |
| `config/sidebar.php` | Item « Gestion des catégories » dans section Administration, gate sur `catalog.categories.view` |
| `frontend/i18n/locales/fr.json` | Clé `sidebar.catalog.categories` = `Gestion des catégories` |
| `frontend/tests/e2e/_fixtures/personas.ts` | `user-full` reçoit les 2 permissions + `'categories'` dans `expectedAdminLinks`. `super-admin` et `ALL_ADMIN_LINKS` étendus avec `'categories'`. `user-readonly` inchangé (Admin-only au M0 — pas de mode read-only spec'é). |
| `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php` | Miroir back : `user-full` reçoit les 2 permissions |

## Décisions

- **`REQUIRED = true`** : la spec § 5.1 + le prompt user disent `true` (Category sera FK NOT NULL côté futurs modules Tiers). Le ticket Lesstime dit `false` par erreur — j'ai suivi la spec.
- **Personas E2E « Admin » = `user-full`** : pas de persona métier « Admin » explicite dans `personas.ts` (personas techniques : `super-admin` bypass, `user-full` = toutes permissions). `user-full` est l'équivalent fonctionnel.
- **`user-readonly` NON touché** : RG-1.01 dit « Admin uniquement », pas de pattern read-only spec'é au M0. À rouvrir dans un futur ticket si besoin.

## Validation

- `make php-cs-fixer-allow-risky` ✓ (0 fichier corrigé)
- `make db-reset` ✓ (sync-permissions : 11 codes en base, dont les 2 nouveaux `catalog.categories.*` vérifiés via `dbal:run-sql`)
- `make test` ✓ (248 tests, 0 régression)
- **RG-1.01 vérifiée manuellement** via curl :
  - Admin → 200 sur `GET /api/categories` et `GET /api/category_types`
  - Bob (zéro permission) → 403 sur `GET /api/categories`, `POST /api/categories`, `GET /api/category_types`
  - Anonyme → 401 sur `GET /api/categories`

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #19
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:45:33 +00:00
matthieu adda62c1e1 [ERP-46] Exposer le référentiel CategoryType en lecture seule (#18)
Auto Tag Develop / tag (push) Successful in 10s
## Contexte

Ticket Lesstime #46 — position 0.4 (M0 Catalog, quick win).

Expose `CategoryType` en lecture seule pour alimenter le `<MalioSelect>` du formulaire `Category` côté front. Pas d'écriture exposée au M0 (table vide à la livraison).

## Mode stacked PR

⚠ **Cible (base branch) : `feature/ERP-45-implementer-provider-processor-category`** (PAS develop).
Quand MR ERP-45 sera mergée sur develop, repointer la cible de cette MR vers develop.

## Changement

Le gros du travail (`#[ApiResource(operations: [GetCollection, Get])]`, security `is_granted('catalog.categories.view')`, groupes de sérialisation) a été livré dans le ticket ERP-44. Cette MR ajoute uniquement ce qui manquait à la spec § 4.6 :

- `order: ['label' => 'ASC']` sur l'opération `GetCollection` → tri alphabétique stable pour le select front.

## Critères d'acceptation (spec § 4.6)

- [x] `GET /api/category_types` retourne tous les `CategoryType` triés par `label ASC`
- [x] `GET /api/category_types/{id}` retourne le détail
- [x] POST / PATCH / DELETE → 404 (opérations non déclarées)
- [x] Security `is_granted('catalog.categories.view')` sur les 2 opérations
- [x] `make php-cs-fixer-allow-risky` passe (0 fix)

## Vérifications

\`\`\`
$ php bin/console debug:router | grep category_type
_api_/category_types{._format}_get_collection   GET   /api/category_types.{_format}
_api_/category_types/{id}{._format}_get         GET   /api/category_types/{id}.{_format}
\`\`\`

→ Exactement 2 routes générées, aucune POST/PATCH/DELETE.

- \`make php-cs-fixer-allow-risky\` ✓ (0 fix)
- \`make test\` ✓ (248/248)

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #18
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:45:06 +00:00
matthieu 80fabcae91 [ERP-45] Implémenter Provider et Processor Category (#17)
Auto Tag Develop / tag (push) Successful in 7s
## Mode stacked PR — cible `feature/ERP-44-creer-entites-category`

> ⚠ Cette MR a pour base la branche ERP-44 (en review). Quand ERP-44 sera mergée sur develop, **repointer la cible vers develop**.

## Résumé
- Provider `CategoryProvider` : filtre soft-delete par défaut (RG-1.08), `?includeDeleted=true` (RG-1.09), tri name ASC (RG-1.10), 404 si soft-deleted hors flag (RG-1.11).
- Processor `CategoryProcessor` : trim du `name` (RG-1.03), conversion DELETE → UPDATE (RG-1.12), mapping `UniqueConstraintViolationException` → HTTP 409 avec message exact (RG-1.07).
- Câblage Provider/Processor dans `#[ApiResource]` de `Category`. Provider câblé aussi sur Patch + Delete (au-delà du scope strict du ticket) pour fermer la fuite RG-1.11 sur PATCH.
- `DoctrineCategoryRepository` expose `createListQueryBuilder($includeDeleted)`.

## Décisions notables
- **Filtre soft-delete via QueryBuilder** (choix `a` du ticket) : pas de filtre Doctrine global, lisibilité directe.
- **Pas de `remove_processor` injecté** : la DELETE est convertie en UPDATE via le `persist_processor`. API Platform 4 utilise le processor déclaré sur l'opération sans fallback automatique.
- **Provider sur Patch + Delete aussi** : décision prise pendant le dev pour fermer une fuite RG-1.11 sur PATCH. Coût : 2 lignes dans `Category.php`.

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make test` ✓ (248 tests, 0 régression — pas de test métier Category, c'est ERP-48)
- Tests manuels curl ✓ — 8 cas RG-1.03 → RG-1.13 validés (détail dans le ticket Lesstime #45)

## Tickets
- Lesstime : #45 (ERP-45) → En review
- Position M0 : 0.3
- Spec : `docs/specs/M0-categories/spec-back.md` § 4.1 + § 4.3 + § 4.4 + § 4.5

## Suite
- ERP-46 (0.4 CategoryType lecture seule) — base : cette branche

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #17
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:44:43 +00:00
matthieu ff6086bc4d [ERP-44] Créer les entités Category et CategoryType (#15)
Auto Tag Develop / tag (push) Successful in 7s
## Objectif
Couche Domain DDD du module Catalog (ticket M0 · position 0.2). Crée les entités `Category` et `CategoryType`, leurs repositories, et branche le pattern Timestampable + Blamable Shared.

> **Mode stacked PR** : cible `feature/ERP-43-migrer-tables-category`. Quand la MR ERP-43 sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop.

## Contenu
- **`Category`** : `#[ApiResource]` (GetCollection, Get, Post, Patch, Delete), `#[Auditable]`, `TimestampableBlamableTrait` + interfaces, asserts (`NotBlank`/`Length` sur `name`, `NotNull` sur `categoryType`), soft delete via `deletedAt`. Provider/Processor branchés au ticket 0.3 (ERP-45).
- **`CategoryType`** : référentiel statique en lecture seule (GetCollection + Get), embarqué dans `Category` via le groupe `category:read`. Pas de Trait — whitelisté dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` (RG-1.17).
- **Repositories** : interfaces Domain + implémentations Doctrine.
- **`config/packages/doctrine.yaml`** : mapping ORM `Catalog` inconditionnel (miroir de `Sites`) — nécessaire pour que l'ORM reconnaisse les entités. La déclaration du module (`config/modules.php`) reste pour le ticket 0.5 (ERP-47).
- Groupes : `category:read` / `category:write` + `default:read` (expose les 4 colonnes du Trait).

## Notes techniques
- Index nommés déclarés sur les entités pour matcher la migration (cf. Role/Permission/Site).
- L'index unique partiel `uq_category_name_type_active` (`LOWER(name), category_type_id WHERE deleted_at IS NULL`) reste possédé par la seule migration : Doctrine ORM ne sait pas exprimer un index fonctionnel + partiel. Seul diff résiduel de `doctrine:schema:validate`.

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make test` ✓ (248 tests, 0 échec)
- `make db-reset` ✓
- `debug:router` ✓ (7 routes exposées)
- `doctrine:schema:validate` : mapping correct

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #15
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:44:18 +00:00
gitea-actions d01bbfbc65 chore: bump version to v0.1.43
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-05-28 09:42:08 +00:00
matthieu 92a6343b66 [ERP-43] Migrer les tables Category et CategoryType (#14)
Auto Tag Develop / tag (push) Successful in 8s
## Ticket
- Lesstime : [#43](https://gitea.malio.fr) — Migrer les tables Category et CategoryType (M0 Catalog, position 0.1)

## Contenu
Migration Doctrine `migrations/Version20260527164000.php` (namespace racine `DoctrineMigrations`, règle ABSOLUE Starseed n°11) :
- Table `category_type` : `id INT IDENTITY`, `code VARCHAR(40)` (UNIQUE), `label VARCHAR(120)`
- Table `category` : `id`, `name`, `category_type_id` (FK RESTRICT), `deleted_at` (soft delete), + 4 colonnes Timestampable/Blamable (`created_at`/`updated_at` NOT NULL, `created_by`/`updated_by` nullable FK `"user"` ON DELETE SET NULL)
- Index unique partiel `uq_category_name_type_active` sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL` → matérialise **RG-1.07**
- Index `idx_category_deleted_at`, `idx_category_type_id`, `idx_category_created_by`, `idx_category_updated_by`

## Tests
- `make php-cs-fixer-allow-risky` ✓
- `make db-reset` ✓ (migration exécutée sans erreur)
- `make test` ✓ — 248 tests / 858 assertions, 0 échec
- Vérification psql `\d category` ✓ (index partiel + 8 colonnes + 3 FK avec les bons ON DELETE)

## ⚠ Mode stacked PR
Cette MR cible `feature/ERP-52-creer-pattern-timestampable-blamable-shared` au lieu de `develop`. Quand la MR #13 (ERP-52) sera mergée sur develop, Matthieu repointera la cible de cette MR vers develop.

Reviewer suggéré : Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #14
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:40:00 +00:00
gitea-actions 02df221a0b chore: bump version to v0.1.42
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 20s
2026-05-28 09:39:02 +00:00
matthieu 6efe7aa8ea [ERP-52] Créer le pattern Timestampable + Blamable Shared (#13)
Auto Tag Develop / tag (push) Successful in 9s
## Contexte
Ticket Lesstime : [#52](https://project.malio-dev.fr/projects/6/tasks/463)
Position dans le groupe M0 : 0.0 (prérequis transverse)

## Implémentation
- 2 interfaces (`TimestampableInterface`, `BlamableInterface`) dans `Shared/Domain/Contract/`
- 1 trait (`TimestampableBlamableTrait`) dans `Shared/Domain/Trait/`
- 1 Subscriber Doctrine (`TimestampableBlamableSubscriber`) dans `Shared/Infrastructure/Doctrine/`
- 1 ligne `resolve_target_entities` ajoutée à `config/packages/doctrine.yaml` (`UserInterface` → `User`)
- 1 test architecture (`EntitiesAreTimestampableBlamableTest`) garde-fou L3 de la spec § 2.8.bis
- 1 test unitaire (`TimestampableBlamableSubscriberTest`) 4 cas

## Décision EXCLUDED (cf. réponse review)
Les 4 entités préexistantes (`User`, `Role`, `Permission`, `Site`) sont **whitelistées** dans `EXCLUDED` avec justification par entrée, plutôt que rétrofitées dans ce ticket. Le rétrofit de `User` et `Site` est documenté en **HP-9 / HP-10** (récursion Blamable + migration → décision archi scopée). Doc mise à jour : spec § 2.8.bis, § 9, et `.claude/rules/backend.md`.

## Tests
- PHPUnit : 5 nouveaux tests, 0 échec, 0 risky (248 tests / 874 assertions au total)
- php-cs-fixer : OK

## Reviewer suggéré
- Tristan

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #13
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:37:18 +00:00
gitea-actions 6c27ac8640 chore: bump version to v0.1.41
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-28 09:33:38 +00:00
matthieu 2ef344e22f ci : speed up PR workflow (nuxt build cache + remove broken runner cache) (#21)
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
2 fixes CI extraits de la MR #13 (ERP-52) pour propreté. Ces commits sont indépendants du pattern Shared.

## Contenu
- db6619a ci(frontend) : accelere le job PR (nuxt build + cache node_modules & build Nuxt/Vite)
- 8035a9d ci : retire tout le caching (backend de cache runner injoignable, timeout 4m30)

## Reviewer suggéré
Matthieu (CI infra).

---------

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #21
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-05-28 09:32:27 +00:00
70 changed files with 5351 additions and 430 deletions
+71
View File
@@ -40,6 +40,27 @@ Format obligatoire : `module.resource[.subresource].action` en snake_case.
- Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire - Audit ManyToMany : trace automatiquement `{fieldName: {added: [ids], removed: [ids]}}` — aucune action supplementaire
- Spec complete : @doc/audit-log.md - Spec complete : @doc/audit-log.md
## 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 ## 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. 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.
@@ -53,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro
## PostgreSQL ## PostgreSQL
- Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO) - 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.
+23 -11
View File
@@ -60,14 +60,9 @@ jobs:
coverage: none coverage: none
tools: composer:v2 tools: composer:v2
- name: Cache Composer # Cache Composer retire : meme cause que cote front — le backend de cache
uses: actions/cache@v4 # du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
with: # ~4 min 30. A re-activer si le serveur de cache du runner est repare.
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: |
composer-
- name: Install PHP dependencies - name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist run: composer install --no-interaction --no-progress --prefer-dist
@@ -78,12 +73,23 @@ jobs:
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database - name: Bootstrap test database
# Aligne sur la cible `test-db-setup` du makefile : apres
# `schema:update --force`, on RECREE manuellement l'index unique
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
# deleted_at IS NULL) et `schema:update` les considere comme
# orphelins et les DROP — collisions non detectees, tests d'unicite
# qui attendent 409 recoivent 201.
run: | run: |
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction 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:migrations:migrate --env=test --no-interaction
php bin/console doctrine:schema:update --env=test --force --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 doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction php bin/console app:sync-permissions --env=test --no-interaction
php bin/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"
- name: Run PHPUnit - name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit run: php -d memory_limit=512M vendor/bin/phpunit
@@ -99,12 +105,15 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
# Pas de `cache: npm` : le backend de cache du runner Gitea est injoignable
# (ETIMEDOUT) et chaque tentative de restauration attend ~4 min 30 avant de
# timeout — c'est ce qui plombait le job. Node 22 est deja dans le
# tool-cache du runner (install instantane), et `npm ci` a froid ne prend
# que ~30s. A re-activer si le serveur de cache du runner est repare.
- name: Setup Node 22 - name: Setup Node 22
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install Node dependencies - name: Install Node dependencies
run: npm ci run: npm ci
@@ -115,5 +124,8 @@ jobs:
- name: Unit tests (Vitest) - name: Unit tests (Vitest)
run: npm run test run: npm run test
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender de generate n'apporte rien a une quality gate — on
# veut seulement valider que le bundle compile.
- name: Build production (nuxt build) - name: Build production (nuxt build)
run: npm run build:dist run: npm run build
+1
View File
@@ -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. 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. 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. 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 ## Conventions
@.claude/rules/architecture.md @.claude/rules/architecture.md
+2
View File
@@ -1,6 +1,7 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Module\Catalog\CatalogModule;
use App\Module\Commercial\CommercialModule; use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule; use App\Module\Core\CoreModule;
use App\Module\Sites\SitesModule; use App\Module\Sites\SitesModule;
@@ -9,4 +10,5 @@ return [
CoreModule::class, CoreModule::class,
CommercialModule::class, CommercialModule::class,
SitesModule::class, SitesModule::class,
CatalogModule::class,
]; ];
+16
View File
@@ -33,6 +33,10 @@ doctrine:
# `App\Module\Sites\Domain\Entity\Site` dans User.php. # `App\Module\Sites\Domain\Entity\Site` dans User.php.
resolve_target_entities: resolve_target_entities:
App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site App\Shared\Domain\Contract\SiteInterface: App\Module\Sites\Domain\Entity\Site
# Cible des ManyToOne created_by / updated_by du TimestampableBlamableTrait.
# Permet a Shared de referencer UserInterface dans ses ORM mappings sans
# importer la classe concrete du module Core (cf. spec-back M0 § 2.8).
Symfony\Component\Security\Core\User\UserInterface: App\Module\Core\Domain\Entity\User
mappings: mappings:
Core: Core:
type: attribute type: attribute
@@ -50,6 +54,18 @@ doctrine:
dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity' dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
prefix: 'App\Module\Sites\Domain\Entity' prefix: 'App\Module\Sites\Domain\Entity'
alias: Sites alias: Sites
# Mapping inconditionnel du module Catalog (meme logique que Sites) :
# la structure DB (category, category_type) existe meme si
# CatalogModule::class n'est pas encore wire dans config/modules.php
# (declaration du module = ticket 0.5 / ERP-47). L'ORM doit connaitre
# les entites pour que le schema soit en phase ; l'activation
# fonctionnelle passe exclusivement par config/modules.php.
Catalog:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Catalog/Domain/Entity'
prefix: 'App\Module\Catalog\Domain\Entity'
alias: Catalog
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false
+7
View File
@@ -83,6 +83,13 @@ return [
'module' => 'sites', 'module' => 'sites',
'permission' => 'sites.view', 'permission' => 'sites.view',
], ],
[
'label' => 'sidebar.catalog.categories',
'to' => '/admin/categories',
'icon' => 'mdi:tag-multiple-outline',
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
[ [
'label' => 'sidebar.core.audit_log', 'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log', 'to' => '/admin/audit-log',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.40' app.version: '0.1.53'
+17
View File
@@ -441,6 +441,21 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0. Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
#### Décision M0 sur la whitelist `EXCLUDED` (ERP-52)
Au moment d'introduire le pattern (ticket 0.0), 4 entités préexistantes vivent déjà sous `src/Module/*/Domain/Entity/` : `User`, `Role`, `Permission` (Core) et `Site` (Sites). Aucune n'implémente le pattern. Le test L3 les détecterait et passerait au rouge.
**Décision** : on **whiteliste explicitement ces 4 entités** dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` avec une justification par entrée, plutôt que de les rétrofiter dans ERP-52 :
| Entité | Justification du `EXCLUDED` |
|---|---|
| `User` | Référentiel d'authentification ; `createdAt` géré manuellement dans le constructeur. Rétrofit non trivial : impose de trancher la récursion Blamable (un `User` créé par un `User`) et casse des tests existants → **HP-9**. |
| `Role` | Référentiel RBAC synchronisé via `app:sync-permissions`, pas de traçabilité user-driven nécessaire. |
| `Permission` | Idem `Role` (synchronisé, pas piloté utilisateur). |
| `Site` | Référentiel admin-managed, rétrofit à intégrer dans un futur module Sites v2 → **HP-10**. |
**Règle dure pour la suite** : toute **nouvelle** entité métier (`Category` au M0, puis `Client`, `Fournisseur`, `Prestataire`, etc.) **doit** implémenter `TimestampableInterface` + `BlamableInterface` via le Trait. La whitelist `EXCLUDED` est réservée aux référentiels statiques justifiés (ex : `CategoryType` au ticket 0.2) — toute nouvelle entrée doit être documentée.
#### Tests Subscriber #### Tests Subscriber
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0. Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0.
@@ -979,6 +994,8 @@ Les deux mécanismes sont indépendants : on peut désactiver `#[Auditable]` (pa
- **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front). - **HP-6** : **Filtres avancés / recherche serveur** dans la liste. Pas pertinent à 300 entrées (pagination front).
- **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée. - **HP-7** : **Catégories hiérarchiques** (parent / enfant). Pas demandé. Si besoin futur → migration ajout colonne `parent_id` + spec dédiée.
- **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules). - **HP-8** : **Création des rôles métier Bureau / Compta / Commerciale / Usine.** Ces rôles font partie du modèle MALIO mais leur seed initial dans `role` + leur attribution aux users est hors du périmètre M0 (probablement un M-RBAC dédié, ou seedés dans `AppFixtures` / `SeedE2ECommand` au fil des modules).
- **HP-9** : **Rétrofit de `User` vers Timestampable + Blamable.** L'entité `User` est whitelistée dans `EntitiesAreTimestampableBlamableTest::EXCLUDED` au M0 (cf. § 2.8.bis). Son rétrofit nécessite une **décision archi dédiée** : récursion Blamable (un `User` créé/modifié par un `User`, FK auto-référente `created_by` / `updated_by` sur la table `user`), impact sur le `createdAt` déjà géré dans le constructeur, et migration des données existantes. À traiter dans un ticket scopé hors M0.
- **HP-10** : **Rétrofit de `Site` vers Timestampable + Blamable.** Même logique que HP-9 pour le référentiel `Site` (whitelisté `EXCLUDED`). À intégrer dans un futur module Sites v2, avec la migration ajoutant les 4 colonnes + FK `user`.
## 10. Liens & dépendances ## 10. Liens & dépendances
+10 -2
View File
@@ -1,9 +1,17 @@
<!--
Valeurs en dur issues de la maquette Figma (design Starseed) :
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
- bande blanche sticky sous la navbar : 47px (h-[47px])
A faire evoluer uniquement avec une mise a jour de maquette.
-->
<template> <template>
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<div class="flex h-full"> <div class="flex h-full">
<MalioSidebar <MalioSidebar
v-model="ui.sidebarCollapsed" v-model="ui.sidebarCollapsed"
:sections="translatedSections" :sections="translatedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
> >
<template #logo> <template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/> <img src="/LOGO_MALIO.png" alt="Malio"/>
@@ -16,10 +24,10 @@
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<SiteSelector v-if="showSiteSelector"/> <SiteSelector v-if="showSiteSelector"/>
<main <main
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16"> class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
<div <div
aria-hidden="true" aria-hidden="true"
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/> class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
<slot/> <slot/>
</main> </main>
</div> </div>
+43
View File
@@ -32,6 +32,9 @@
}, },
"sites": { "sites": {
"admin": "Sites" "admin": "Sites"
},
"catalog": {
"categories": "Gestion des catégories"
} }
}, },
"dashboard": { "dashboard": {
@@ -85,12 +88,19 @@
}, },
"empty": "Aucune activité enregistrée", "empty": "Aucune activité enregistrée",
"no_results": "Aucun résultat pour ces filtres", "no_results": "Aucun résultat pour ces filtres",
"error": {
"title": "Erreur",
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
},
"timeline": { "timeline": {
"empty": "Aucun historique", "empty": "Aucun historique",
"load_more": "Voir plus" "load_more": "Voir plus"
}, },
"filters": { "filters": {
"title": "Filtres",
"apply": "Voir les résultats",
"reset": "Réinitialiser", "reset": "Réinitialiser",
"date_range": "Date à date",
"date_from": "Du", "date_from": "Du",
"date_to": "Au", "date_to": "Au",
"entity_type": "Type d'entité", "entity_type": "Type d'entité",
@@ -220,6 +230,39 @@
"updated": "Site mis à jour avec succès", "updated": "Site mis à jour avec succès",
"deleted": "Site supprimé avec succès" "deleted": "Site supprimé avec succès"
} }
},
"categories": {
"title": "Gestion des catégories",
"newCategory": "Ajouter",
"editCategory": "Modifier la catégorie",
"createCategory": "Créer une catégorie",
"viewCategory": "Détail de la catégorie",
"noCategories": "Aucune catégorie pour l'instant.",
"table": {
"name": "Nom",
"type": "Type"
},
"form": {
"name": "Nom",
"type": "Type de catégorie",
"typePlaceholder": "Sélectionner un type"
},
"validation": {
"nameRequired": "Le nom est obligatoire.",
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
"typeRequired": "Le type de catégorie est obligatoire."
},
"delete": {
"title": "Supprimer la catégorie",
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
},
"toast": {
"created": "Catégorie créée avec succès",
"updated": "Catégorie mise à jour avec succès",
"deleted": "Catégorie supprimée avec succès",
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
} }
} }
} }
@@ -0,0 +1,48 @@
<template>
<MalioModal
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.categories.delete.title') }}
</h3>
</template>
<p class="text-sm text-neutral-600">
{{ t('admin.categories.delete.message', { name: categoryName }) }}
</p>
<template #footer>
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="emit('confirm')"
/>
</template>
</MalioModal>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
categoryName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
</script>
@@ -0,0 +1,178 @@
<template>
<MalioDrawer
:model-value="modelValue"
drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-2xl font-bold">
{{ headerLabel }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
<MalioInputText
v-model="form.name.value"
:label="t('admin.categories.form.name')"
input-class="w-full"
:max-length="120"
:error="form.errors.value.name"
required
/>
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
number (categoryType id) ; conversion en IRI au moment du save
par le composable useCategoryForm. -->
<MalioSelect
v-model="form.categoryTypeId.value"
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.value.categoryType"
:disabled="loadingTypes"
/>
<!-- Erreur transverse (typiquement reseau / 5xx) separe des
erreurs de validation par champ. -->
<p v-if="form.errors.value._global" class="text-sm text-red-600">
{{ form.errors.value._global }}
</p>
</form>
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="canShowDelete"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
v-if="canShowSave"
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="form.submitting.value || loadingTypes"
@click="handleSave"
/>
</template>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
// n'est pas singleton, contrairement a useCategoriesAdmin).
const form = useCategoryForm()
const props = defineProps<{
modelValue: boolean
category: Category | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
/**
* Mode du drawer (dérivé du composable `useCategoryForm`) :
* - 'create' : pas de category prop, formulaire vide, POST au save.
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
* jusqu'a ce que l'utilisateur modifie un champ.
* - 'edit' : category prop set et formulaire « dirty » (au moins un
* champ different de l'original), PATCH au save.
*/
type DrawerMode = 'create' | 'view' | 'edit'
const isCreateMode = computed(() => props.category === null)
const mode = computed<DrawerMode>(() => {
if (isCreateMode.value) return 'create'
return form.isDirty.value ? 'edit' : 'view'
})
const headerLabel = computed(() => {
if (mode.value === 'create') return t('admin.categories.createCategory')
if (mode.value === 'edit') return t('admin.categories.editCategory')
return t('admin.categories.viewCategory')
})
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
// existante et seulement pour les users ayant la permission manage. En mode
// creation on affiche un bouton Annuler a la place.
const canShowDelete = computed(
() => !isCreateMode.value && can('catalog.categories.manage'),
)
// Save : visible en creation, ou en edition (apres modification d'un champ).
// Masque en view tant que rien n'a change.
const canShowSave = computed(
() => mode.value === 'create' || mode.value === 'edit',
)
const typeOptions = computed(() =>
types.value.map(ct => ({
label: ct.label,
value: ct.id,
})),
)
// Re-initialise le form quand la categorie selectionnee change (clic sur une
// autre ligne sans fermer le drawer entre-temps).
watch(() => props.category, (cat) => {
form.loadFrom(cat)
}, { immediate: true })
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
// d'optimisation cache au M0, le referentiel est petit).
watch(
() => props.modelValue,
(open) => {
if (open) {
form.loadFrom(props.category)
fetchTypes()
}
},
)
/**
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
* refresh la liste.
*/
async function handleSave(): Promise<void> {
let result: Category | null = null
if (mode.value === 'create') {
result = await form.submitCreate()
} else if (mode.value === 'edit' && props.category) {
result = await form.submitUpdate(props.category.id)
}
if (result) {
emit('saved')
emit('update:modelValue', false)
}
}
</script>
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
// Mock du store auth : useCategoriesAdmin s'auto-enregistre via
// `onAuthSessionCleared(...)` au chargement du module. On stubbe pour
// eviter de charger Pinia et la vraie store (pas necessaire ici).
vi.mock('~/shared/stores/auth', () => ({
onAuthSessionCleared: vi.fn(),
}))
// Le client API est un auto-import Nuxt. On le remplace par un stub
// global pour intercepter les appels et controler les reponses dans
// chaque test (cf. pattern utilise dans useCurrentSite.spec.ts).
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
}))
// Import APRES vi.mock / vi.stubGlobal : le module n'est evalue qu'a
// ce moment-la, donc le mock auth est bien actif au top-level.
const { useCategoriesAdmin } = await import('../useCategoriesAdmin')
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT_A: Category = {
id: 10,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
const CAT_B: Category = {
id: 11,
name: 'Boulons',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-02T10:00:00+00:00',
updatedAt: '2026-01-02T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
function makeHydra<T>(items: T[]): HydraCollection<T> {
return {
totalItems: items.length,
member: items,
}
}
describe('useCategoriesAdmin', () => {
beforeEach(() => {
mockGet.mockReset()
// Reset systematique du state singleton entre tests : sans ca,
// les categories chargees dans un test fuiteraient dans le suivant.
const { resetCategoriesAdmin } = useCategoriesAdmin()
resetCategoriesAdmin()
})
describe('fetchAll', () => {
it('appelle GET /categories avec itemsPerPage=999 par defaut', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple categories.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([CAT_A, CAT_B]))
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([CAT_A, CAT_B])
})
it('exclut les soft-deleted par defaut (pas de query includeDeleted)', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll()
const queryArg = mockGet.mock.calls[0]?.[1] as Record<string, unknown>
expect(queryArg).not.toHaveProperty('includeDeleted')
})
it('ajoute includeDeleted=true quand demande explicitement', async () => {
mockGet.mockResolvedValueOnce(makeHydra<Category>([]))
const { fetchAll } = useCategoriesAdmin()
await fetchAll(true)
expect(mockGet).toHaveBeenCalledWith(
'/categories',
{ itemsPerPage: 999, includeDeleted: 'true' },
{ toast: false },
)
})
it('passe loading a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<Category>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchAll, loading } = useCategoriesAdmin()
const pending = fetchAll()
expect(loading.value).toBe(true)
resolveRequest(makeHydra<Category>([]))
await pending
expect(loading.value).toBe(false)
})
it('peuple error.value et vide categories en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('Network down'))
const { fetchAll, categories, error, loading } = useCategoriesAdmin()
// Pre-charge volontairement quelque chose pour verifier la purge.
categories.value = [CAT_A]
await fetchAll()
expect(categories.value).toEqual([])
expect(error.value).toBe('Network down')
expect(loading.value).toBe(false)
})
it('gere une reponse sans champ member (fallback tableau vide)', async () => {
mockGet.mockResolvedValueOnce({
totalItems: 0,
} as unknown as HydraCollection<Category>)
const { fetchAll, categories } = useCategoriesAdmin()
await fetchAll()
expect(categories.value).toEqual([])
})
})
describe('fetchTypes', () => {
it('appelle GET /category_types avec itemsPerPage=999', async () => {
mockGet.mockResolvedValueOnce(makeHydra<CategoryType>([]))
const { fetchTypes } = useCategoriesAdmin()
await fetchTypes()
expect(mockGet).toHaveBeenCalledWith(
'/category_types',
{ itemsPerPage: 999 },
{ toast: false },
)
})
it('peuple types.value depuis le champ Hydra member', async () => {
mockGet.mockResolvedValueOnce(makeHydra([TYPE_VENTE, TYPE_ACHAT]))
const { fetchTypes, types } = useCategoriesAdmin()
await fetchTypes()
expect(types.value).toEqual([TYPE_VENTE, TYPE_ACHAT])
})
it('peuple error.value et vide types en cas d echec', async () => {
mockGet.mockRejectedValueOnce(new Error('500'))
const { fetchTypes, types, error, loadingTypes } = useCategoriesAdmin()
types.value = [TYPE_VENTE]
await fetchTypes()
expect(types.value).toEqual([])
expect(error.value).toContain('500')
expect(loadingTypes.value).toBe(false)
})
it('passe loadingTypes a true pendant la requete et false apres', async () => {
let resolveRequest: (v: HydraCollection<CategoryType>) => void = () => {}
mockGet.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const { fetchTypes, loadingTypes } = useCategoriesAdmin()
const pending = fetchTypes()
expect(loadingTypes.value).toBe(true)
resolveRequest(makeHydra<CategoryType>([]))
await pending
expect(loadingTypes.value).toBe(false)
})
})
describe('resetCategoriesAdmin', () => {
it('vide categories, types, loading, loadingTypes et error', () => {
const { resetCategoriesAdmin, categories, types, loading, loadingTypes, error }
= useCategoriesAdmin()
// Pre-peuple le state pour verifier la purge effective.
categories.value = [CAT_A]
types.value = [TYPE_VENTE]
loading.value = true
loadingTypes.value = true
error.value = 'oops'
resetCategoriesAdmin()
expect(categories.value).toEqual([])
expect(types.value).toEqual([])
expect(loading.value).toBe(false)
expect(loadingTypes.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('singleton', () => {
it('deux appels a useCategoriesAdmin() partagent la meme ref categories', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
// Les fonctions sont reinstanciees a chaque appel mais les refs
// doivent etre rigoureusement les memes (state au niveau module).
expect(a.categories).toBe(b.categories)
expect(a.types).toBe(b.types)
expect(a.loading).toBe(b.loading)
})
it('une mutation via une instance est visible depuis une autre instance', () => {
const a = useCategoriesAdmin()
const b = useCategoriesAdmin()
a.categories.value = [CAT_A]
expect(b.categories.value).toEqual([CAT_A])
})
})
})
@@ -0,0 +1,454 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import { useCategoryForm } from '../useCategoryForm'
// Stubs des auto-imports Nuxt consommes par le composable.
const mockGet = vi.hoisted(() => vi.fn())
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockDelete = vi.hoisted(() => vi.fn())
const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: mockDelete,
}))
vi.stubGlobal('useToast', () => ({
success: mockToastSuccess,
error: mockToastError,
}))
// useI18n.t : on renvoie la cle telle quelle (pratique pour asserter dessus).
// Quand le composable passe des params (ex: doublon), on les serialise pour
// pouvoir verifier que l'interpolation a bien recu le bon nom.
vi.stubGlobal('useI18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}::${JSON.stringify(params)}` : key,
}))
const TYPE_VENTE: CategoryType = { id: 1, code: 'VENTE', label: 'Vente' }
const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = {
id: 42,
name: 'Vis',
categoryType: TYPE_VENTE,
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
createdBy: null,
updatedBy: null,
}
describe('useCategoryForm', () => {
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
mockPatch.mockReset()
mockDelete.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
})
describe('loadFrom', () => {
it('pre-remplit le formulaire depuis une categorie existante', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
})
it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm()
form.name.value = 'old'
form.categoryTypeId.value = 99
form.loadFrom(null)
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
})
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
})
})
describe('isDirty', () => {
it('passe a true des qu une valeur diverge du snapshot initial', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
form.name.value = 'Vis modifie'
expect(form.isDirty.value).toBe(true)
})
})
describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm()
form.name.value = ' '
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameRequired')
})
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'.repeat(121)
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = null
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.value.categoryType).toBe('admin.categories.validation.typeRequired')
})
it('passe quand name et categoryType sont valides', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const ok = form.validate()
expect(ok).toBe(true)
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
})
it('reinitialise les erreurs avant chaque validation', () => {
const form = useCategoryForm()
// Erreur prealable.
form.errors.value._global = 'erreur ancienne'
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.validate()
expect(form.errors.value._global).toBe('')
})
})
describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = ' Vis '
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith(
'/categories',
{ name: 'Vis', categoryType: '/api/category_types/1' },
{ toast: false },
)
expect(result).toEqual(CAT)
})
it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(mockPost).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('declenche un toast de succes en cas de creation reussie', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
await form.submitCreate()
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
message: 'admin.categories.toast.created',
})
})
it('mappe un 409 (RG-1.07) sur errors.name + toast erreur avec le nom', async () => {
mockPost.mockRejectedValueOnce({
response: { status: 409, _data: {} },
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(result).toBeNull()
// La cle est interpolee avec le nom soumis : on retrouve "Vis" dans
// les params i18n (stub serialise les params).
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Vis"')
expect(mockToastError).toHaveBeenCalledTimes(1)
const toastArg = mockToastError.mock.calls[0]?.[0] as { message: string }
expect(toastArg.message).toContain('Vis')
})
it('mappe un 422 violations sur les champs concernes (errors.name)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
violations: [
{ propertyPath: 'name', message: 'name should not be blank.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const result = await form.submitCreate()
expect(result).toBeNull()
expect(form.errors.value.name).toBe('name should not be blank.')
// Pas de toast quand on a mappe les violations : l erreur est
// affichee inline sous le champ concerne.
expect(mockToastError).not.toHaveBeenCalled()
})
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
'hydra:violations': [
{ propertyPath: 'categoryType', message: 'Type invalide.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
await form.submitCreate()
expect(form.errors.value.categoryType).toBe('Type invalide.')
})
it('fallback en erreur globale + toast si le status n est ni 409 ni 422', async () => {
mockPost.mockRejectedValueOnce({
response: { status: 500, _data: { 'hydra:description': 'Boom server' } },
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
await form.submitCreate()
expect(form.errors.value._global).toBe('Boom server')
expect(mockToastError).toHaveBeenCalledWith({
title: 'Erreur',
message: 'Boom server',
})
})
it('passe submitting a true pendant la requete et a false apres', async () => {
let resolveRequest: (v: Category) => void = () => {}
mockPost.mockImplementationOnce(
() => new Promise((resolve) => { resolveRequest = resolve }),
)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
const pending = form.submitCreate()
expect(form.submitting.value).toBe(true)
resolveRequest(CAT)
await pending
expect(form.submitting.value).toBe(false)
})
})
describe('submitUpdate', () => {
it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2' // categoryTypeId inchange
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2' }, // pas de categoryType car non modifie
{ toast: false },
)
})
it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
const form = useCategoryForm()
form.loadFrom(CAT)
form.categoryTypeId.value = 2
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryType: '/api/category_types/2' },
{ toast: false },
)
})
it('court-circuite l appel API si aucun champ n a change', async () => {
const form = useCategoryForm()
form.loadFrom(CAT)
// Aucune modification — isDirty=false, patch payload vide.
const result = await form.submitUpdate(42)
expect(mockPatch).not.toHaveBeenCalled()
expect(result).toBeNull()
expect(form.submitting.value).toBe(false)
})
it('declenche un toast de succes au PATCH reussi', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2'
await form.submitUpdate(42)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
message: 'admin.categories.toast.updated',
})
})
it('mappe le 409 sur errors.name en mode update aussi', async () => {
mockPatch.mockRejectedValueOnce({
response: { status: 409, _data: {} },
})
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Doublon'
const result = await form.submitUpdate(42)
expect(result).toBeNull()
expect(form.errors.value.name).toContain('admin.categories.toast.duplicate')
expect(form.errors.value.name).toContain('"name":"Doublon"')
})
})
describe('submitDelete', () => {
it('appelle DELETE /categories/{id} et declenche un toast succes', async () => {
mockDelete.mockResolvedValueOnce(undefined)
const form = useCategoryForm()
const ok = await form.submitDelete(42)
expect(mockDelete).toHaveBeenCalledWith('/categories/42', {}, { toast: false })
expect(ok).toBe(true)
expect(mockToastSuccess).toHaveBeenCalledWith({
title: 'Succès',
message: 'admin.categories.toast.deleted',
})
})
it('retourne false et toast erreur en cas d echec', async () => {
mockDelete.mockRejectedValueOnce({
response: { status: 500, _data: { detail: 'down' } },
})
const form = useCategoryForm()
const ok = await form.submitDelete(42)
expect(ok).toBe(false)
expect(form.errors.value._global).toBe('down')
expect(mockToastError).toHaveBeenCalled()
})
})
describe('reset', () => {
it('vide le formulaire et les erreurs', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'edit'
form.errors.value._global = 'erreur'
form.submitting.value = true
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.errors.value).toEqual({ name: '', categoryType: '', _global: '' })
expect(form.submitting.value).toBe(false)
})
})
describe('isolation', () => {
it('deux instances useCategoryForm() ont des states independants', () => {
const a = useCategoryForm()
const b = useCategoryForm()
a.name.value = 'A'
b.name.value = 'B'
expect(a.name.value).toBe('A')
expect(b.name.value).toBe('B')
// Les refs sont distinctes (pas singleton — chaque drawer son state).
expect(a.name).not.toBe(b.name)
})
})
})
@@ -0,0 +1,134 @@
/**
* Composable d'administration des categories (M0 — Gestion des categories).
*
* Centralise le chargement et le state des deux ressources lues par la page
* `/admin/categories` : la liste des categories et le referentiel
* CategoryType (utilise dans le select du drawer).
*
* State singleton au niveau module (meme convention que `useSidebar` /
* `useModules` / `useAuditLog`) : reset automatique au logout via
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
* avec state singleton doivent etre reinitialises au logout »), et reset
* explicite expose via `resetCategoriesAdmin()` appele depuis
* `modules/core/pages/logout.vue`.
*/
import { ref } from 'vue'
import type { Category, CategoryType } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
import { onAuthSessionCleared } from '~/shared/stores/auth'
/**
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
* toute la liste en un coup. A basculer en pagination serveur quand la
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
*/
const HYDRA_NO_PAGINATION = 999
// State singleton — partage entre tous les composants qui appellent le
// composable dans la meme session. Les refs sont declarees au niveau module
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
// instance soit creee a chaque appel.
const categories = ref<Category[]>([])
const types = ref<CategoryType[]>([])
const loading = ref(false)
const loadingTypes = ref(false)
const error = ref<string | null>(null)
function resetCategoriesAdminState(): void {
categories.value = []
types.value = []
loading.value = false
loadingTypes.value = false
error.value = null
}
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
// `resetCategoriesAdmin()` ci-dessous.
onAuthSessionCleared(resetCategoriesAdminState)
export function useCategoriesAdmin() {
const api = useApi()
/**
* Charge la liste des categories. Le serveur exclut les soft-deleted par
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
*
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
* option mais on l'expose pour la suite (corbeille future).
*
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
* la page seulement par URL directe et on affiche un tableau vide propre.
*/
async function fetchAll(includeDeleted = false): Promise<void> {
loading.value = true
error.value = null
try {
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
if (includeDeleted) {
query.includeDeleted = 'true'
}
const data = await api.get<HydraCollection<Category>>(
'/categories',
query,
{ toast: false },
)
categories.value = data.member ?? []
} catch (e) {
categories.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement'
} finally {
loading.value = false
}
}
/**
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
* l'ouverture de la page admin pour que le select du drawer ait deja les
* options pretes au moment de la creation/edition.
*
* Toast desactive : on stocke l'erreur dans `error` plutot que de
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
*/
async function fetchTypes(): Promise<void> {
loadingTypes.value = true
try {
const data = await api.get<HydraCollection<CategoryType>>(
'/category_types',
{ itemsPerPage: HYDRA_NO_PAGINATION },
{ toast: false },
)
types.value = data.member ?? []
} catch (e) {
types.value = []
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
} finally {
loadingTypes.value = false
}
}
/**
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
* garantir que la prochaine session reparte sur un state propre meme si
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
*/
function resetCategoriesAdmin(): void {
resetCategoriesAdminState()
}
return {
categories,
types,
loading,
loadingTypes,
error,
fetchAll,
fetchTypes,
resetCategoriesAdmin,
}
}
@@ -0,0 +1,319 @@
/**
* Composable de formulaire categorie (M0 — Gestion des categories).
*
* Centralise la logique de validation client + appels API (POST / PATCH /
* DELETE) du drawer de creation/edition. Contrairement a
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
* ce composable est instancie par formulaire (les refs vivent dans la
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
* isole.
*
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
* revalide toujours (defense en profondeur).
*
* Mapping erreurs API :
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
* - 422 (violations API Platform) → mapping sur les champs concernes
* - autre → erreur globale `_global` + toast generique
*/
import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category'
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
/**
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
* (status et payload data) pour eviter de typer toute la lib.
*/
interface ApiFetchError {
response?: {
status?: number
_data?: unknown
}
}
export function useCategoryForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('')
const categoryTypeId = ref<number | null>(null)
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation).
const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null)
const errors = ref<{
name: string
categoryType: string
_global: string
}>({
name: '',
categoryType: '',
_global: '',
})
const submitting = ref(false)
const isDirty = computed(
() =>
name.value !== initialName.value
|| categoryTypeId.value !== initialCategoryTypeId.value,
)
/**
* Pre-remplit le formulaire a partir d'une categorie existante (mode
* consultation/edition) ou vide (mode creation). Reinitialise les
* erreurs et le snapshot initial pour repartir d'un etat propre.
*/
function loadFrom(category: Category | null): void {
errors.value = { name: '', categoryType: '', _global: '' }
if (category) {
name.value = category.name
categoryTypeId.value = category.categoryType.id
initialName.value = category.name
initialCategoryTypeId.value = category.categoryType.id
} else {
name.value = ''
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeId.value = null
}
}
/**
* Validation client miroir des RG back. Renvoie true si tout passe et
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
* mais le serveur retrim de toute facon — pas de risque de divergence.
*/
function validate(): boolean {
errors.value = { name: '', categoryType: '', _global: '' }
const trimmedName = name.value.trim()
// RG-1.02 — name obligatoire (vide / whitespace-only).
if (trimmedName === '') {
errors.value.name = t('admin.categories.validation.nameRequired')
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
// RG-1.04 — longueur 2-120 apres trim.
errors.value.name = t('admin.categories.validation.nameLength')
}
// RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) {
errors.value.categoryType = t('admin.categories.validation.typeRequired')
}
return errors.value.name === '' && errors.value.categoryType === ''
}
/**
* Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee. Retourne un object literal
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
* en TS strict).
*/
function buildCreatePayload(): Record<string, unknown> {
return {
name: name.value.trim(),
categoryType: `/api/category_types/${categoryTypeId.value}`,
}
}
/**
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
* Renvoie true des qu'au moins une violation a ete posee — false sinon
* (payload sans violations exploitables, ou tous les `propertyPath` hors
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
* sur les futurs drawers de formulaire.
*/
function mapServerViolations(data: unknown): boolean {
const violations = extractApiViolations(data)
if (violations.length === 0) return false
let mapped = false
for (const v of violations) {
if (v.propertyPath === 'name') {
errors.value.name = v.message
mapped = true
} else if (v.propertyPath === 'categoryType') {
errors.value.categoryType = v.message
mapped = true
}
}
return mapped
}
/**
* Traite une erreur API : mappe selon le status, declenche les toasts
* appropries. Centralise la logique entre create/update.
*
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
* le nom soumis.
* - 422 : tentative de mapping fin via les violations API Platform — si au
* moins une violation est mappee, pas de toast (erreur affichee inline
* sous le champ concerne).
* - autre : message global + toast generique. Le toast natif d'useApi
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
*
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
* false sinon (fallback generique).
*/
function handleApiError(e: unknown, attemptedName: string): boolean {
const status = (e as ApiFetchError)?.response?.status
const data = (e as ApiFetchError)?.response?._data
if (status === 409) {
const duplicateMessage = t('admin.categories.toast.duplicate', {
name: attemptedName,
})
errors.value.name = duplicateMessage
toast.error({
title: 'Erreur',
message: duplicateMessage,
})
return true
}
if (status === 422 && mapServerViolations(data)) {
return true
}
const extracted = extractApiErrorMessage(data)
errors.value._global = extracted || 'Une erreur est survenue.'
toast.error({
title: 'Erreur',
message: errors.value._global,
})
return false
}
/**
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
* validation client a echoue ou si le serveur a renvoye une erreur. Le
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
*/
async function submitCreate(): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload = buildCreatePayload()
try {
const created = await api.post<Category>('/categories', payload, {
toast: false,
})
toast.success({
title: 'Succès',
message: t('admin.categories.toast.created'),
})
return created
} catch (e) {
handleApiError(e, String(payload.name))
return null
} finally {
submitting.value = false
}
}
/**
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
* coller a la semantique merge-patch (Content-Type pose par useApi).
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
*/
async function submitUpdate(id: number): Promise<Category | null> {
if (!validate()) return null
submitting.value = true
errors.value._global = ''
const payload: Record<string, unknown> = {}
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
}
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
// on protege le composable contre un appel direct mal utilise.
if (Object.keys(payload).length === 0) {
submitting.value = false
return null
}
try {
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
toast: false,
})
toast.success({
title: 'Succès',
message: t('admin.categories.toast.updated'),
})
return updated
} catch (e) {
const attemptedName = typeof payload.name === 'string'
? payload.name
: name.value.trim()
handleApiError(e, attemptedName)
return null
} finally {
submitting.value = false
}
}
/**
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
* false sinon (avec toast erreur deja affiche).
*/
async function submitDelete(id: number): Promise<boolean> {
submitting.value = true
errors.value._global = ''
try {
await api.delete(`/categories/${id}`, {}, { toast: false })
toast.success({
title: 'Succès',
message: t('admin.categories.toast.deleted'),
})
return true
} catch (e) {
handleApiError(e, name.value)
return false
} finally {
submitting.value = false
}
}
/**
* Reset complet du formulaire — utilise par le drawer apres save ou
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
*/
function reset(): void {
name.value = ''
categoryTypeId.value = null
initialName.value = ''
initialCategoryTypeId.value = null
errors.value = { name: '', categoryType: '', _global: '' }
submitting.value = false
}
return {
// State
name,
categoryTypeId,
errors,
submitting,
isDirty,
// Methods
loadFrom,
validate,
submitCreate,
submitUpdate,
submitDelete,
reset,
}
}
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,139 @@
<template>
<div>
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</template>
</PageHeader>
<!-- Table des categories. Affichage exhaustif (volumetrie cible
<= 300, cf. spec § 4.1) tri 100% serveur via CategoryProvider
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
reste cosmetique tant qu'aucun slice client n'est cable : a
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
:total-items="categories.length"
:row-clickable="true"
:empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick"
/>
<!-- Drawer creation / consultation / edition. -->
<CategoryDrawer
v-model="drawerOpen"
:category="selectedCategory"
@saved="onCategorySaved"
@delete="onDeleteRequest"
/>
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
<CategoryDeleteModal
v-model="deleteModalOpen"
:category-name="categoryToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
const drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// on aplatit en label lisible pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typeLabel: cat.categoryType?.label ?? '',
})),
)
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const category = getCategoryById(item.id as number)
if (category) openEditDrawer(category)
}
function openCreateDrawer() {
selectedCategory.value = null
drawerOpen.value = true
}
function openEditDrawer(category: Category) {
selectedCategory.value = category
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedCategory.value) return
categoryToDelete.value = selectedCategory.value
deleteModalOpen.value = true
}
/**
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
* de la liste a la fin pour retirer la ligne. L'index unique partiel
* autorise une recreation ulterieure avec le meme couple (name, type) —
* RG-1.07.
*/
async function handleDelete(): Promise<void> {
if (!categoryToDelete.value) return
deleting.value = true
try {
const ok = await submitDelete(categoryToDelete.value.id)
if (ok) {
deleteModalOpen.value = false
categoryToDelete.value = null
drawerOpen.value = false
await fetchAll()
}
} finally {
deleting.value = false
}
}
function onCategorySaved() {
fetchAll()
}
// Chargement initial des deux ressources (liste + referentiel des types).
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
onMounted(() => {
fetchAll()
fetchTypes()
})
</script>
@@ -0,0 +1,71 @@
/**
* Types front du module Catalog (M0 — Gestion des categories).
*
* Contrats API consommes :
* - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection<CategoryType>
*
* Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType, cf. spec-back § 3.4).
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
/**
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
* et de l'username pour l'affichage courant.
*/
export interface User {
id: number
username: string
}
/**
* Reference du referentiel CategoryType (lecture seule au M0).
*/
export interface CategoryType {
id: number
code: string
label: string
}
/**
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
*/
export interface Category {
id: number
name: string
categoryType: CategoryType
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
updatedAt: string
createdBy: User | null
updatedBy: User | null
}
/**
* Payload accepte en POST /api/categories. `categoryType` est envoye en
* IRI Hydra (ex. `/api/category_types/3`).
*/
export interface CategoryCreateInput {
name: string
categoryType: string
}
/**
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
* optionnels (modification partielle).
*/
export interface CategoryUpdateInput {
name?: string
categoryType?: string
}
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1> <PageHeader>{{ $t('commercial.title') }}</PageHeader>
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p> <p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
</div> </div>
</template> </template>
@@ -0,0 +1,71 @@
<template>
<!-- Accordeon de permissions groupees par module : un panneau par module,
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
et liste des permissions individuelles. Source unique de cette UX, utilisee
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
<MalioAccordion v-model="openModules">
<MalioAccordionItem
v-for="group in groupsByModule"
:key="group.module"
:value="group.module"
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
header-class="capitalize"
>
<div class="flex flex-col gap-3">
<!-- Tout selectionner pour ce module -->
<MalioCheckbox
:id="`${idPrefix}-group-${group.module}`"
:label="t('admin.roles.permissions.selectAll')"
:model-value="allSelectedFor(group)"
label-class="font-semibold text-sm text-neutral-700"
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
/>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="perm in group.permissions"
:id="`${idPrefix}-perm-${perm.id}`"
:key="perm.id"
:label="perm.label"
:model-value="selectedIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
/>
</div>
</div>
</MalioAccordionItem>
</MalioAccordion>
</template>
<script setup lang="ts">
import type { PermissionModule } from '~/shared/types/rbac'
const { t } = useI18n()
const props = defineProps<{
/** Groupes de permissions a afficher, un par module. */
groupsByModule: PermissionModule[]
/** Ids des permissions actuellement selectionnees. */
selectedIds: Set<number>
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
idPrefix: string
}>()
const emit = defineEmits<{
toggle: [permissionId: number, selected: boolean]
'toggle-all': [module: string, selected: boolean]
}>()
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
// du composant garde sa propre liste, pas de partage entre drawers.
const openModules = ref<string[]>([])
// Nombre de permissions selectionnees pour un module donne.
function selectedCountFor(group: PermissionModule): number {
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
}
// Vrai si toutes les permissions du module sont selectionnees.
function allSelectedFor(group: PermissionModule): boolean {
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
}
</script>
@@ -1,66 +0,0 @@
<template>
<div class="rounded-lg border border-neutral-200 overflow-hidden">
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
<MalioCheckbox
:id="`group-${module}`"
:label="moduleLabel"
:model-value="allSelected"
label-class="font-semibold text-sm text-neutral-700 capitalize"
@update:model-value="toggleAll"
/>
<span class="ml-auto text-xs text-neutral-400">
{{ selectedCount }}/{{ permissions.length }}
</span>
</div>
<!-- Liste des permissions individuelles -->
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
<MalioCheckbox
v-for="perm in permissions"
:key="perm.id"
:id="`perm-${perm.id}`"
:label="perm.label"
:model-value="selectedIds.has(perm.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Permission } from '~/shared/types/rbac'
const props = defineProps<{
module: string
moduleLabel: string
permissions: Permission[]
selectedIds: Set<number>
}>()
const emit = defineEmits<{
toggle: [permissionId: number, selected: boolean]
toggleAll: [module: string, selected: boolean]
}>()
// Nombre de permissions selectionnees dans ce groupe
const selectedCount = computed(() =>
props.permissions.filter(p => props.selectedIds.has(p.id)).length
)
// Vrai si toutes les permissions du groupe sont selectionnees
const allSelected = computed(() =>
props.permissions.length > 0 && selectedCount.value === props.permissions.length
)
// Emet l'evenement de bascule pour une permission individuelle
function togglePermission(id: number, selected: boolean) {
emit('toggle', id, selected)
}
// Emet l'evenement de bascule pour toutes les permissions du groupe
function toggleAll(selected: boolean) {
emit('toggleAll', props.module, selected)
}
</script>
+46 -44
View File
@@ -1,11 +1,17 @@
<template> <template>
<MalioDrawer <MalioDrawer
:model-value="modelValue" :model-value="modelValue"
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
drawer-class="w-full max-w-lg" drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave"> <template #header>
<h2 class="text-[24px] font-bold">
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<!-- Champs du role --> <!-- Champs du role -->
<MalioInputText <MalioInputText
v-model="form.label" v-model="form.label"
@@ -44,55 +50,51 @@
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400"> <div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }} {{ t('admin.roles.permissions.noPermissions') }}
</div> </div>
<div class="flex flex-col gap-4"> <PermissionAccordion
<PermissionGroup v-else
v-for="group in permissionsByModule" :groups-by-module="permissionsByModule"
:key="group.module" :selected-ids="selectedPermissionIds"
:module="group.module" id-prefix="role"
:module-label="group.module" @toggle="handleTogglePermission"
:permissions="group.permissions" @toggle-all="handleToggleAll"
:selected-ids="selectedPermissionIds" />
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</div>
</div> </div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || permissionsLoadFailed"
@click="handleSave"
/>
</div>
</form> </form>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
:disabled="role?.isSystem"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || permissionsLoadFailed"
@click="handleSave"
/>
</template>
</MalioDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Permission, Role } from '~/shared/types/rbac' import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
interface PermissionModule {
module: string
permissions: Permission[]
}
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
@@ -1,11 +1,17 @@
<template> <template>
<MalioDrawer <MalioDrawer
:model-value="modelValue" :model-value="modelValue"
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })" drawer-class="w-full max-w-[450px]"
drawer-class="w-full max-w-lg" header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<div class="flex flex-col gap-6 p-4"> <template #header>
<h2 class="text-[24px] font-bold">
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
</h2>
</template>
<div class="flex flex-col gap-4 py-4">
<!-- Etat d'erreur de chargement des referentiels : bloque la <!-- Etat d'erreur de chargement des referentiels : bloque la
sauvegarde pour empecher un ecrasement silencieux des droits. --> sauvegarde pour empecher un ecrasement silencieux des droits. -->
<div <div
@@ -60,18 +66,14 @@
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400"> <div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
{{ t('admin.roles.permissions.noPermissions') }} {{ t('admin.roles.permissions.noPermissions') }}
</div> </div>
<div class="flex flex-col gap-4"> <PermissionAccordion
<PermissionGroup v-else
v-for="group in permissionsByModule" :groups-by-module="permissionsByModule"
:key="group.module" :selected-ids="selectedDirectPermissionIds"
:module="group.module" id-prefix="direct"
:module-label="group.module" @toggle="handleTogglePermission"
:permissions="group.permissions" @toggle-all="handleToggleAll"
:selected-ids="selectedDirectPermissionIds" />
@toggle="handleTogglePermission"
@toggle-all="handleToggleAll"
/>
</div>
</div> </div>
<!-- Section Sites autorises (ticket 2 module Sites) --> <!-- Section Sites autorises (ticket 2 module Sites) -->
@@ -103,33 +105,32 @@
<EffectivePermissions :permissions="effectivePermissions" /> <EffectivePermissions :permissions="effectivePermissions" />
</div> </div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || loadFailed"
@click="handleSave"
/>
</div>
</div> </div>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || loadFailed"
@click="handleSave"
/>
</template>
</MalioDrawer> </MalioDrawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac' import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites' import type { Site } from '~/shared/types/sites'
interface PermissionModule {
module: string
permissions: Permission[]
}
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const auth = useAuthStore() const auth = useAuthStore()
+185 -179
View File
@@ -1,95 +1,22 @@
<template> <template>
<div> <div>
<div class="flex items-center justify-between"> <PageHeader>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl"> {{ t('admin.auditLog.title') }}
{{ t('admin.auditLog.title') }} <template #actions>
</h1>
</div>
<!-- Filtres -->
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
leur `label` flottant interne pour ne pas mixer deux patterns de label.
A revoir une fois le composant calendar Malio développé -->
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
exposera un datetime picker. Cf. exception documentee dans
CLAUDE.md (section "Composants formulaires"). -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_from') }}
</label>
<input
v-model="filters.performedAtAfter"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<!-- TODO(malio-ui): idem ci-dessus. -->
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.date_to') }}
</label>
<input
v-model="filters.performedAtBefore"
type="datetime-local"
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.entity_type') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelectCheckbox
v-model="selectedEntityTypes"
:options="entityTypeOptions"
:display-select-all="true"
:display-tag="true"
min-width="w-full"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.user') }}
</label>
<MalioInputText
v-model="performedByInput"
icon-name="mdi:account-search"
input-class="text-sm"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600">
{{ t('audit.filters.action') }}
</label>
<div class="[&>div>div]:!mt-0">
<MalioSelect
v-model="actionValue"
:options="actionOptions"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
</div>
<div class="mt-3 flex justify-end">
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
:label="t('audit.filters.reset')" :label="t('audit.filters.title')"
button-class="text-xs" icon-name="mdi:tune"
@click="resetFilters" icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/> />
</div> </template>
</section> </PageHeader>
<!-- Tableau --> <!-- Tableau -->
<MalioDataTable <MalioDataTable
class="mt-4"
:columns="columns" :columns="columns"
:items="rows" :items="rows"
:total-items="totalItems" :total-items="totalItems"
@@ -123,12 +50,99 @@
</template> </template>
</MalioDataTable> </MalioDataTable>
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
bord a bord (les items portent leur propre px-7). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
<span>{{ t('audit.filters.date_from') }}</span>
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
<MalioDateTime
v-model="draftDateFrom"
:max="draftDateTo ?? undefined"
/>
<span>{{ t('audit.filters.date_to') }}</span>
<MalioDateTime
v-model="draftDateTo"
:min="draftDateFrom ?? undefined"
/>
</div>
</MalioAccordionItem>
<!-- Type d'entite : cases a cocher (multi-selection) -->
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
<div class="flex flex-col gap-4">
<MalioCheckbox
v-for="opt in entityTypeOptions"
:id="`filter-entity-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftEntityTypes.includes(opt.value)"
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
/>
</div>
</MalioAccordionItem>
<!-- Action : boutons radio (selection unique, '' = toutes) -->
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
<MalioRadioButton
v-for="opt in actionOptions"
:key="opt.value"
v-model="draftAction"
name="audit-action"
:value="opt.value"
:label="opt.label"
/>
</MalioAccordionItem>
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
<MalioInputText
v-model="draftPerformedBy"
icon-name="mdi:account-search"
/>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('audit.filters.reset')"
button-class="w-[150px]"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('audit.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
<!-- Drawer detail : diff courant + timeline complete de l'entite --> <!-- Drawer detail : diff courant + timeline complete de l'entite -->
<MalioDrawer <MalioDrawer
v-model="drawerOpen" v-model="drawerOpen"
:title="drawerTitle"
drawer-class="max-w-2xl" drawer-class="max-w-2xl"
> >
<template #header>
<h2 class="text-[24px] font-bold">
{{ drawerTitle }}
</h2>
</template>
<div v-if="selectedEntry"> <div v-if="selectedEntry">
<AuditLogDetail :entry="selectedEntry" /> <AuditLogDetail :entry="selectedEntry" />
<div class="mt-4 border-t border-gray-200 pt-3"> <div class="mt-4 border-t border-gray-200 pt-3">
@@ -149,12 +163,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types' import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
const { t, te } = useI18n() const { t, te } = useI18n()
const { can } = usePermissions() const { can } = usePermissions()
const { fetchLogsCached, fetchEntityTypes } = useAuditLog() const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
const toast = useToast()
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en // Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune // libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
@@ -173,8 +188,11 @@ if (!can('core.audit_log.view')) {
useHead({ title: t('admin.auditLog.title') }) useHead({ title: t('admin.auditLog.title') })
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle // Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
// CLAUDE.md "Tableau : pas de persistance URL"). // persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
// au moment du fetch.
const filters = reactive<AuditLogFilters>({ const filters = reactive<AuditLogFilters>({
performedAtAfter: undefined, performedAtAfter: undefined,
performedAtBefore: undefined, performedAtBefore: undefined,
@@ -185,26 +203,23 @@ const filters = reactive<AuditLogFilters>({
itemsPerPage: 10, itemsPerPage: 10,
}) })
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox. // Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`). // uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
const selectedEntityTypes = ref<(string | number)[]>([]) // fermant le drawer sans relancer de requete.
const filterDrawerOpen = ref(false)
const draftDateFrom = ref<string | null>(null)
const draftDateTo = ref<string | null>(null)
const draftEntityTypes = ref<string[]>([])
const draftAction = ref<string>('')
const draftPerformedBy = ref<string>('')
// Liste des entity types (distincts) pour alimenter les cases a cocher.
const entityTypes = ref<string[]>([]) const entityTypes = ref<string[]>([])
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
const entityTypeOptions = computed(() => const entityTypeOptions = computed(() =>
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })), entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
) )
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut // Actions : '' = "toutes". Sert d'options aux boutons radio.
// pas binder directement un `string | undefined` reactive.
const performedByInput = ref<string>('')
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
// car MalioSelect grise visuellement les options dont la valeur est `null`
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
const actionValue = ref<string>('')
const actionOptions = [ const actionOptions = [
{ value: '', label: t('audit.filters.all_actions') }, { value: '', label: t('audit.filters.all_actions') },
{ value: 'create', label: t('audit.action.create') }, { value: 'create', label: t('audit.action.create') },
@@ -259,29 +274,55 @@ const isFiltered = computed(() =>
// (reseau lent) n'ecrase les resultats d'une requete ulterieure. // (reseau lent) n'ecrase les resultats d'une requete ulterieure.
let requestToken = 0 let requestToken = 0
// Pendant un reset, on suspend temporairement les watchers pour ne pas // Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3 // reouverture reflete les filtres actifs.
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de function openFilters(): void {
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent draftDateFrom.value = filters.performedAtAfter ?? null
// et les fetchs partent quand meme. Un seul loadEntries() est appele draftDateTo.value = filters.performedAtBefore ?? null
// explicitement apres la liberation. draftEntityTypes.value = Array.isArray(filters.entityType)
let watchersSuspended = false ? [...filters.entityType]
: (filters.entityType ? [filters.entityType] : [])
draftAction.value = filters.action ?? ''
draftPerformedBy.value = filters.performedBy ?? ''
filterDrawerOpen.value = true
}
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
function toggleEntity(value: string, selected: boolean): void {
draftEntityTypes.value = selected
? [...draftEntityTypes.value, value]
: draftEntityTypes.value.filter(v => v !== value)
}
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
// La remise a zero s'applique immediatement (la table revient a la liste
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftDateFrom.value = null
draftDateTo.value = null
draftEntityTypes.value = []
draftAction.value = ''
draftPerformedBy.value = ''
async function resetFilters(): Promise<void> {
watchersSuspended = true
filters.performedAtAfter = undefined filters.performedAtAfter = undefined
filters.performedAtBefore = undefined filters.performedAtBefore = undefined
filters.entityType = undefined filters.entityType = undefined
filters.performedBy = undefined
filters.action = undefined filters.action = undefined
filters.performedBy = undefined
filters.page = 1 filters.page = 1
selectedEntityTypes.value = [] loadEntries()
performedByInput.value = '' }
actionValue.value = ''
// Les watchers mute de Vue 3 se planifient en microtask : on attend // "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
// leur execution avec le flag `true`, puis on libere. function applyFilters(): void {
await nextTick() filters.performedAtAfter = draftDateFrom.value ?? undefined
watchersSuspended = false filters.performedAtBefore = draftDateTo.value ?? undefined
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
filters.action = draftAction.value === '' ? undefined : draftAction.value
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
filters.page = 1
filterDrawerOpen.value = false
loadEntries() loadEntries()
} }
@@ -291,7 +332,8 @@ async function loadEntries(): Promise<void> {
try { try {
const data = await fetchLogsCached({ const data = await fetchLogsCached({
...filters, ...filters,
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API. // MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined, performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined, performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
}) })
@@ -300,13 +342,19 @@ async function loadEntries(): Promise<void> {
if (token !== requestToken) return if (token !== requestToken) return
entries.value = data.member ?? [] entries.value = data.member ?? []
totalItems.value = data.totalItems ?? 0 totalItems.value = data.totalItems ?? 0
} catch { } catch (err) {
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas // useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
// laisser l'utilisateur croire que les donnees affichees sont a jour. // les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog. // une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
// devant une table vide indistinguable d'un filtre a zero resultat.
if (token === requestToken) { if (token === requestToken) {
entries.value = [] entries.value = []
totalItems.value = 0 totalItems.value = 0
console.error('[audit-log] loadEntries failed', err)
toast.error({
title: t('audit.error.title'),
message: t('audit.error.message'),
})
} }
} finally { } finally {
if (token === requestToken) { if (token === requestToken) {
@@ -315,14 +363,9 @@ async function loadEntries(): Promise<void> {
} }
} }
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
const debouncedReload = debounce(() => loadEntries(), 300)
function toIso(localDateTime: string): string { function toIso(localDateTime: string): string {
// datetime-local n'a pas de timezone : on assume heure locale et on // MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
// laisse le navigateur generer l'ISO via Date(). // on laisse Date() generer l'ISO UTC correspondant pour l'API.
return new Date(localDateTime).toISOString() return new Date(localDateTime).toISOString()
} }
@@ -368,53 +411,16 @@ function onPerPageChange(value: number): void {
loadEntries() loadEntries()
} }
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
watch(selectedEntityTypes, values => {
if (watchersSuspended) return
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
filters.page = 1
loadEntries()
})
// Sync MalioSelect action -> filters.action.
watch(actionValue, value => {
if (watchersSuspended) return
filters.action = value === '' ? undefined : value
filters.page = 1
loadEntries()
})
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
// refetch par caractere. Le reset passe par debouncedReload egalement pour
// coalescer si plusieurs watchers tirent en meme temps.
watch(performedByInput, value => {
if (watchersSuspended) return
filters.performedBy = value === '' ? undefined : value
filters.page = 1
debouncedReload()
})
// Synchronisation reactive : tout changement de dates declenche un fetch +
// reset de la pagination a la page 1.
watch(
() => [filters.performedAtAfter, filters.performedAtBefore],
() => {
if (watchersSuspended) return
filters.page = 1
loadEntries()
},
)
onMounted(async () => { onMounted(async () => {
// Charge les entity types en parallele de la liste principale : un // Charge les entity types ET la liste principale en parallele (TTFD divise
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher // par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le // de /audit-log-entity-types ne bloque pas l'affichage du tableau —
// filtre vide — l'utilisateur pourra quand meme consulter le journal. // l'utilisateur perd juste le filtre, pas la page entiere.
try { await Promise.all([
entityTypes.value = await fetchEntityTypes() fetchEntityTypes()
} catch { .then(types => { entityTypes.value = types })
entityTypes.value = [] .catch(() => { entityTypes.value = [] }),
} loadEntries(),
await loadEntries() ])
}) })
</script> </script>
+12 -14
View File
@@ -1,22 +1,20 @@
<template> <template>
<div> <div>
<!-- En-tete --> <PageHeader>
<div class="flex items-center justify-between"> {{ t('admin.roles.title') }}
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl"> <template #actions>
{{ t('admin.roles.title') }} <MalioButton
</h1> v-if="can('core.roles.manage')"
<MalioButton :label="t('admin.roles.newRole')"
v-if="can('core.roles.manage')" icon-name="mdi:add-bold"
:label="t('admin.roles.newRole')" icon-position="left"
icon-name="mdi:add-bold" @click="openCreateDrawer"
icon-position="left" />
@click="openCreateDrawer" </template>
/> </PageHeader>
</div>
<!-- Table des roles --> <!-- Table des roles -->
<MalioDataTable <MalioDataTable
class="mt-6"
:columns="columns" :columns="columns"
:items="roleItems" :items="roleItems"
:total-items="roles.length" :total-items="roles.length"
+1 -7
View File
@@ -1,15 +1,9 @@
<template> <template>
<div> <div>
<!-- En-tete --> <PageHeader>{{ t('admin.users.title') }}</PageHeader>
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.users.title') }}
</h1>
</div>
<!-- Table des utilisateurs --> <!-- Table des utilisateurs -->
<MalioDataTable <MalioDataTable
class="mt-6"
:columns="columns" :columns="columns"
:items="userItems" :items="userItems"
:total-items="users.length" :total-items="users.length"
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1> <PageHeader>{{ $t('dashboard.title') }}</PageHeader>
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p> <p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
</div> </div>
</template> </template>
+2
View File
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
const { resetModules } = useModules() const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite() const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog() const { resetAuditLog } = useAuditLog()
const { resetCategoriesAdmin } = useCategoriesAdmin()
onMounted(async () => { onMounted(async () => {
try { try {
@@ -27,6 +28,7 @@ onMounted(async () => {
resetModules() resetModules()
resetCurrentSite() resetCurrentSite()
resetAuditLog() resetAuditLog()
resetCategoriesAdmin()
await navigateTo('/login') await navigateTo('/login')
} }
}) })
@@ -1,11 +1,17 @@
<template> <template>
<MalioDrawer <MalioDrawer
:model-value="modelValue" :model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg" drawer-class="w-full max-w-lg"
header-class="border-b border-black"
footer-class="justify-between border-t border-black p-6"
@update:model-value="emit('update:modelValue', $event)" @update:model-value="emit('update:modelValue', $event)"
> >
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave"> <template #header>
<h2 class="text-[24px] font-bold">
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
</h2>
</template>
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
:label="t('admin.sites.form.name')" :label="t('admin.sites.form.name')"
@@ -70,30 +76,35 @@
</p> </p>
</div> </div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</div>
</form> </form>
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
scrollable (shrink-0), donc reellement fige sans sticky. -->
<template #footer>
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
button-class="w-[150px]"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
button-class="w-[150px]"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</template>
</MalioDrawer> </MalioDrawer>
</template> </template>
+12 -14
View File
@@ -1,22 +1,20 @@
<template> <template>
<div> <div>
<!-- En-tete --> <PageHeader>
<div class="flex items-center justify-between"> {{ t('admin.sites.title') }}
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl"> <template #actions>
{{ t('admin.sites.title') }} <MalioButton
</h1> v-if="can('sites.manage')"
<MalioButton :label="t('admin.sites.newSite')"
v-if="can('sites.manage')" icon-name="mdi:add-bold"
:label="t('admin.sites.newSite')" icon-position="left"
icon-name="mdi:add-bold" @click="openCreateDrawer"
icon-position="left" />
@click="openCreateDrawer" </template>
/> </PageHeader>
</div>
<!-- Table des sites --> <!-- Table des sites -->
<MalioDataTable <MalioDataTable
class="mt-6"
:columns="columns" :columns="columns"
:items="siteItems" :items="siteItems"
:total-items="sites.length" :total-items="sites.length"
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.5.0", "@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.5.0", "version": "1.7.1",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==", "integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.5.0", "@malio/layer-ui": "^1.7.1",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -0,0 +1,12 @@
<template>
<!-- Entete de page standard : source unique du style des titres.
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
<div class="mb-[44px] flex items-center justify-between gap-4">
<h1 class="text-[32px] font-semibold text-primary-500">
<slot/>
</h1>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions"/>
</div>
</div>
</template>
+3 -18
View File
@@ -1,5 +1,6 @@
import type { FetchOptions , FetchError } from 'ofetch' import type { FetchOptions , FetchError } from 'ofetch'
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { extractApiErrorMessage } from '~/shared/utils/api'
export type AnyObject = Record<string, unknown> export type AnyObject = Record<string, unknown>
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
function extractErrorMessage(error: unknown, responseData?: unknown): string { function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data const data = responseData ?? (error as FetchError)?.data
const msg = extractApiErrorMessage(data)
if (typeof data === 'string') { if (msg) return msg
return data
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string) ||
(record.detail as string) ||
(record.message as string) ||
(record.error as string) ||
(record.title as string) ||
(record['hydra:title'] as string) ||
''
)
}
return (error as FetchError)?.message ?? 'Erreur inconnue.' return (error as FetchError)?.message ?? 'Erreur inconnue.'
} }
+9
View File
@@ -43,3 +43,12 @@ export interface EffectivePermission {
module: string module: string
sources: string[] sources: string[]
} }
/**
* Groupement de permissions par module pour l'affichage en accordeon.
* Construit cote consommateur a partir de la liste plate /api/permissions.
*/
export interface PermissionModule {
module: string
permissions: Permission[]
}
+59
View File
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] { export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
return collection.member ?? [] return collection.member ?? []
} }
/**
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
* pointe le champ concerne, `message` est le libelle a afficher.
*/
export interface ApiViolation {
propertyPath: string
message: string
}
/**
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
* les deux formats de negociation (`violations` ou `hydra:violations`) et
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
*
* Utilise par useCategoryForm et tout futur composable de formulaire qui
* doit mapper les violations serveur sur ses champs.
*/
export function extractApiViolations(data: unknown): ApiViolation[] {
if (!data || typeof data !== 'object') return []
const record = data as Record<string, unknown>
const raw = record.violations ?? record['hydra:violations']
if (!Array.isArray(raw)) return []
const out: ApiViolation[] = []
for (const v of raw) {
if (!v || typeof v !== 'object') continue
const obj = v as Record<string, unknown>
out.push({
propertyPath: String(obj.propertyPath ?? ''),
message: String(obj.message ?? ''),
})
}
return out
}
/**
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
* `hydra:description` → `detail` → `description` → `message` → `error` →
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
*
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
* Symfony en text/plain ou des messages bruts).
*/
export function extractApiErrorMessage(data: unknown): string {
if (typeof data === 'string') return data
if (!data || typeof data !== 'object') return ''
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string)
?? (record.detail as string)
?? (record.description as string)
?? (record.message as string)
?? (record.error as string)
?? (record.title as string)
?? (record['hydra:title'] as string)
?? ''
)
}
+6 -4
View File
@@ -35,7 +35,7 @@ export interface Persona {
// sidebar-visibility pour driver la matrice. Les valeurs correspondent // sidebar-visibility pour driver la matrice. Les valeurs correspondent
// aux slugs de route (`/admin/<slug>`), volontairement stables quand // aux slugs de route (`/admin/<slug>`), volontairement stables quand
// la copie/i18n change. // la copie/i18n change.
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log'> expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
} }
const SHARED_PASSWORD = 'e2e-secret' const SHARED_PASSWORD = 'e2e-secret'
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
password: SHARED_PASSWORD, password: SHARED_PASSWORD,
isAdmin: true, isAdmin: true,
permissions: [], permissions: [],
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
}, },
'user-full': { 'user-full': {
key: 'user-full', key: 'user-full',
@@ -63,8 +63,10 @@ export const personas: Record<PersonaKey, Persona> = {
'sites.view', 'sites.view',
'sites.manage', 'sites.manage',
'sites.bypass_scope', 'sites.bypass_scope',
'catalog.categories.view',
'catalog.categories.manage',
], ],
expectedAdminLinks: ['users', 'roles', 'sites', 'audit-log'], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
}, },
'user-readonly': { 'user-readonly': {
key: 'user-readonly', key: 'user-readonly',
@@ -109,4 +111,4 @@ export function getPersona(key: PersonaKey): Persona {
return personas[key] return personas[key]
} }
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'audit-log'] as const export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log' export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
/** /**
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin". * Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
+14
View File
@@ -200,12 +200,26 @@ migration-migrate:
# en DB, le purger crash. # en DB, le purger crash.
# 3. fixtures -> sync-permissions : fixtures:load purge la table permission, # 3. fixtures -> sync-permissions : fixtures:load purge la table permission,
# donc sync doit passer apres. # donc sync doit passer apres.
# 4. recreation index `uq_category_name_type_active` : schema:update drop
# les index orphelins du mapping ORM. L'index partiel (LOWER + WHERE) du
# M0 Catalog n'est pas exprimable via les attributs Doctrine ORM 3
# (fonctionnel + partiel), donc il disparait apres schema:update. On le
# 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: test-db-setup:
$(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists $(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction $(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction
$(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force $(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 doctrine:fixtures:load
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(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"
fixtures: fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M0 — Catalog : creation des tables `category_type` (referentiel) et `category`.
*
* Le referentiel `category_type` est cree vide ; ses valeurs seront seedees
* ulterieurement (cf. spec-back M0 § 9 HP-1).
*
* Index unique partiel sur (LOWER(name), category_type_id) WHERE deleted_at
* IS NULL : permet la recreation d'une categorie apres suppression logique
* (cf. RG-1.07). Postgres supporte nativement le `CREATE UNIQUE INDEX ... WHERE`.
*
* Les 4 colonnes Timestampable/Blamable (`created_at`, `updated_at`,
* `created_by`, `updated_by`) materialisent le pattern Shared (cf. ERP-52,
* spec-back M0 § 2.8) : NOT NULL pour les dates (remplies par le subscriber),
* nullable + ON DELETE SET NULL pour les FK user (creation hors contexte HTTP
* et suppression d'un user sans bloquer les categories existantes).
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
* Starseed n°11) : avec plusieurs migrations_paths, Doctrine Migrations 3.x
* trie par FQCN alphabetique et non par version timestamp → l'init des tables
* d'un module doit vivre au namespace racine pour garantir l'ordre sur base
* vide.
*/
final class Version20260527164000 extends AbstractMigration
{
public function getDescription(): string
{
return 'M0 Catalog : tables category_type et category, index unique partiel.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE category_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_category_type_code ON category_type (code)');
$this->addSql(<<<'SQL'
CREATE TABLE category (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(120) NOT NULL,
category_type_id INT NOT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_category_type
FOREIGN KEY (category_type_id) REFERENCES category_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_category_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_category_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Unicite (name, type) case-insensitive, seulement sur les non-soft-deleted.
$this->addSql(<<<'SQL'
CREATE UNIQUE INDEX uq_category_name_type_active
ON category (LOWER(name), category_type_id)
WHERE deleted_at IS NULL
SQL);
$this->addSql('CREATE INDEX idx_category_deleted_at ON category (deleted_at)');
$this->addSql('CREATE INDEX idx_category_type_id ON category (category_type_id)');
$this->addSql('CREATE INDEX idx_category_created_by ON category (created_by)');
$this->addSql('CREATE INDEX idx_category_updated_by ON category (updated_by)');
}
public function down(Schema $schema): void
{
// Ordre important : `category` porte les FK vers `category_type`.
$this->addSql('DROP TABLE category');
$this->addSql('DROP TABLE category_type');
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-67 — Retrofit `COMMENT ON COLUMN` / `COMMENT ON TABLE` sur toutes les
* tables metier existantes.
*
* Postgres stocke la description dans `pg_description`. Les outils d'admin
* (DBeaver, DataGrip, pgAdmin) l'affichent automatiquement, ce qui evite de
* remonter au code Doctrine pour comprendre la semantique d'une colonne.
*
* Source unique : `ColumnCommentsCatalog::comments()`. Le meme catalogue est
* rejoue par `app:apply-column-comments` apres `doctrine:schema:update --force`
* en environnement de test (Doctrine ORM ne conservant pas les commentaires
* absents du mapping PHP).
*
* Convention :
* - Description en francais, ≤ 200 caracteres.
* - Semantique du champ + contraintes / lien RG si pertinent.
*
* Migration placee au namespace racine `DoctrineMigrations` (regle ABSOLUE
* Starseed n°11) car elle touche plusieurs modules. Les futures migrations
* applicatives devront poser leur propre `COMMENT ON COLUMN` au moment de
* creer leurs colonnes (cf. regle ABSOLUE n°12 + .claude/rules/backend.md).
*/
final class Version20260528120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-67 : retrofit COMMENT ON COLUMN/TABLE sur toutes les tables metier existantes.';
}
public function up(Schema $schema): void
{
foreach (ColumnCommentsCatalog::toSqlStatements() as $sql) {
$this->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).'"',
));
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog;
final class CatalogModule
{
public const string ID = 'catalog';
public const string LABEL = 'Catalogue';
// REQUIRED = true : Category sera FK NOT NULL cote futurs modules Tiers
// (M-Clients, M-Fournisseurs, M-Prestataires). Desactiver Catalog casserait
// tout le metier au boot Doctrine. Cf. review Tristan MR #12 + spec M0 § 2.1.
public const bool REQUIRED = true;
/**
* Liste declarative des permissions RBAC exposees par le module Catalog.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* Granularite alignee sur Core (view + manage), pas view/create/edit/delete
* (cf. spec M0 § 2.7).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
];
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
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\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\CategoryProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Categorie : referentiel metier classifiant les futurs tiers (clients,
* fournisseurs, prestataires). Porte un `name` libre et un `categoryType`
* (FK vers le referentiel statique CategoryType).
*
* - Soft delete via `deletedAt` (pas de hard delete) : la liste exclut par
* defaut les categories supprimees (cf. CategoryProvider, ticket 0.3).
* - Timestampable + Blamable via le Trait Shared : les 4 colonnes
* created_at / updated_at / created_by / updated_by sont remplies
* automatiquement par le TimestampableBlamableSubscriber.
* - `#[Auditable]` : chaque create / update / delete (soft) est trace dans
* audit_log par l'AuditListener du module Core.
*
* Provider (filtre soft-delete + ?includeDeleted + tri name ASC + 404 sur
* soft-deleted) et Processor (trim, 409 sur doublon, soft delete) branches
* au ticket 0.3 (ERP-45).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class,
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class,
),
new Post(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
denormalizationContext: ['groups' => ['category:write']],
processor: CategoryProcessor::class,
),
new Patch(
security: "is_granted('catalog.categories.manage')",
normalizationContext: ['groups' => ['category:read', 'default:read']],
denormalizationContext: ['groups' => ['category:write']],
provider: CategoryProvider::class,
processor: CategoryProcessor::class,
),
new Delete(
security: "is_granted('catalog.categories.manage')",
provider: CategoryProvider::class,
processor: CategoryProcessor::class,
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
#[ORM\Table(name: 'category')]
// Index nommes pour matcher la migration (cf. Role/Permission/Site). L'index
// unique partiel `uq_category_name_type_active` (LOWER(name), category_type_id
// WHERE deleted_at IS NULL) reste possede par la seule migration : Doctrine ORM
// ne sait pas exprimer un index fonctionnel + partiel via attribut.
#[ORM\Index(name: 'idx_category_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_category_type_id', columns: ['category_type_id'])]
#[ORM\Index(name: 'idx_category_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_category_updated_by', columns: ['updated_by'])]
#[Auditable]
class Category implements TimestampableInterface, BlamableInterface
{
// === Timestampable + Blamable ===
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
// getters/setters viennent du Trait Shared. Le TimestampableBlamableSubscriber
// les remplit automatiquement au prePersist / preUpdate. Aucune redeclaration
// manuelle de ces proprietes ici.
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category:read'])]
private ?int $id = null;
// RG-1.02 + RG-1.03 : un name compose uniquement d'espaces doit declencher
// NotBlank. Le normalizer 'trim' fait le menage avant validation, alignant
// le comportement sur le trim cote Processor (qui s'applique apres) : ainsi
// POST {name: " "} -> 422 et POST {name: " Vis "} -> 201 avec "Vis"
// persiste, sans contradiction entre l'ordre Validate / Process.
#[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 120, normalizer: 'trim')]
#[Groups(['category:read', 'category:write'])]
private ?string $name = null;
#[ORM\ManyToOne(targetEntity: CategoryType::class)]
#[ORM\JoinColumn(name: 'category_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Type de catégorie obligatoire.')]
#[Groups(['category:read', 'category:write'])]
private ?CategoryType $categoryType = null;
/**
* Soft delete : null = active, valeur = supprimee logiquement le {date}.
* Pas exposee en ecriture (groupe category:write exclu) : seul le DELETE,
* via le CategoryProcessor (ticket 0.3), pose la valeur.
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['category:read'])]
private ?DateTimeImmutable $deletedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCategoryType(): ?CategoryType
{
return $this->categoryType;
}
public function setCategoryType(?CategoryType $categoryType): static
{
$this->categoryType = $categoryType;
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryTypeRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de categorie : referentiel statique classifiant les Category
* (ex: MATIERE, PRODUIT, SERVICE). Cree vide par la migration M0 ; son seed
* initial et son CRUD admin sont hors perimetre M0 (cf. spec-back § 9 HP-1).
*
* Lecture seule au M0 : seules les operations GetCollection et Get sont
* exposees, sous la meme permission que Category (catalog.categories.view).
*
* Referentiel statique : pas de Timestampable/Blamable (whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe `category:read`
* est ajoute sur chaque propriete pour que le type soit embarque dans la
* reponse d'une Category (cf. .claude/rules/backend.md § Serialization).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']],
// Tri par defaut requis par la spec M0 § 4.6 : ordre alphabetique
// stable pour alimenter le <MalioSelect> du formulaire Category.
order: ['label' => 'ASC'],
),
new Get(
security: "is_granted('catalog.categories.view')",
normalizationContext: ['groups' => ['category_type:read']],
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineCategoryTypeRepository::class)]
#[ORM\Table(name: 'category_type')]
// Contrainte d'unicite nommee pour matcher la migration (cf. Role/Permission).
#[ORM\UniqueConstraint(name: 'uq_category_type_code', columns: ['code'])]
class CategoryType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['category_type:read', 'category:read'])]
private ?int $id = null;
#[ORM\Column(length: 40)]
#[Groups(['category_type:read', 'category:read'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['category_type:read', 'category:read'])]
private ?string $label = null;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Category;
use Doctrine\ORM\QueryBuilder;
interface CategoryRepositoryInterface
{
public function findById(int $id): ?Category;
public function save(Category $category): void;
/**
* Construit un QueryBuilder de liste avec filtre soft-delete et tri par defaut.
* - $includeDeleted = false : exclut les categories soft-deleted (RG-1.08)
* - Tri : name ASC (RG-1.10).
*/
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\CategoryType;
interface CategoryTypeRepositoryInterface
{
public function findById(int $id): ?CategoryType;
/**
* @return list<CategoryType>
*/
public function findAllOrderedByLabel(): array;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Domain\Entity\Category;
use DateTimeImmutable;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Processor Category : applique les regles de gestion en ecriture.
*
* - POST / PATCH : trim du nom (RG-1.03) puis delegation au persist_processor
* Doctrine ORM. Toute UniqueConstraintViolationException remontee par Postgres
* (collision sur l'index partiel uq_category_name_type_active) est traduite
* en HTTP 409 avec le message attendu par la spec (RG-1.07).
* - DELETE : soft delete (RG-1.12). On NE delegue PAS au remove_processor ;
* on pose deletedAt = now() puis on delegue au persist_processor pour que
* le UPDATE Doctrine parte et que le TimestampableBlamableSubscriber mette
* a jour updatedAt / updatedBy (RG-1.16) en plus de l'AuditListener.
*
* @implements ProcessorInterface<Category, null|Category>
*/
final class CategoryProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Category) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// RG-1.12 : soft delete au lieu d'un remove physique. On bascule la DELETE
// en UPDATE en posant deletedAt, puis on persiste via le persist_processor.
if ($operation instanceof DeleteOperationInterface) {
$data->setDeletedAt(new DateTimeImmutable());
try {
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Par construction, le soft delete ne peut pas violer l'index
// partiel (il LIBERE le couple (name, type) au lieu de le creer).
// On laisse remonter en 500 pour signaler une anomalie reelle.
throw $e;
}
return null;
}
// POST / PATCH : trim du nom avant validation et persistance (RG-1.03).
if (null !== $data->getName()) {
$data->setName(trim($data->getName()));
}
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// RG-1.07 : doublon (LOWER(name), category_type_id) parmi les non-soft-deleted.
throw new HttpException(
409,
sprintf('Une catégorie nommée "%s" existe déjà pour ce type.', $data->getName() ?? ''),
$e,
);
}
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider Category : applique le filtre soft-delete par defaut (RG-1.08),
* accepte ?includeDeleted=true pour inclure les soft-deleted (RG-1.09),
* trie par name ASC (RG-1.10), et renvoie 404 sur Get d'une soft-deleted
* sans le flag (RG-1.11, via retour null).
*
* Choix d'implementation : QueryBuilder via le repository custom plutot
* qu'un filtre Doctrine global (cf. spec § 2.3 et arbitrage ticket 0.3).
* Avantage : pas de magie globale, lisibilite directe du code, controle
* fin du flag includeDeleted par requete.
*
* @implements ProviderInterface<Category>
*/
final class CategoryProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineCategoryRepository')]
private readonly CategoryRepositoryInterface $repository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Category|iterable|null
{
$includeDeleted = $this->readIncludeDeleted($context);
if ($operation instanceof CollectionOperationInterface) {
return $this->repository
->createListQueryBuilder($includeDeleted)
->getQuery()
->getResult()
;
}
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$category = $this->repository->findById((int) $id);
if (null === $category) {
return null;
}
// RG-1.11 : 404 si soft-deleted et pas de flag includeDeleted.
if (!$includeDeleted && null !== $category->getDeletedAt()) {
return null;
}
return $category;
}
/**
* Lit le flag includeDeleted depuis les filtres API Platform.
* Accepte "true" / "1" / true (booleen).
*/
private function readIncludeDeleted(array $context): bool
{
$raw = $context['filters']['includeDeleted'] ?? false;
if (is_bool($raw)) {
return $raw;
}
if (is_string($raw)) {
return in_array(strtolower($raw), ['true', '1'], true);
}
return false;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Repository\CategoryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Category>
*/
class DoctrineCategoryRepository extends ServiceEntityRepository implements CategoryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Category::class);
}
public function findById(int $id): ?Category
{
return $this->find($id);
}
public function save(Category $category): void
{
$this->getEntityManager()->persist($category);
$this->getEntityManager()->flush();
}
public function createListQueryBuilder(bool $includeDeleted = false): QueryBuilder
{
$qb = $this->createQueryBuilder('c')
->orderBy('c.name', 'ASC')
;
if (!$includeDeleted) {
$qb->andWhere('c.deletedAt IS NULL');
}
return $qb;
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Repository\CategoryTypeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CategoryType>
*/
class DoctrineCategoryTypeRepository extends ServiceEntityRepository implements CategoryTypeRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CategoryType::class);
}
public function findById(int $id): ?CategoryType
{
return $this->find($id);
}
/**
* @return list<CategoryType>
*/
public function findAllOrderedByLabel(): array
{
return $this->findBy([], ['label' => 'ASC']);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:apply-column-comments',
description: 'Reapplique les COMMENT ON TABLE/COLUMN du catalogue (workaround schema:update).',
)]
final class ApplyColumnCommentsCommand extends Command
{
public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$statements = ColumnCommentsCatalog::toSqlStatements();
foreach ($statements as $sql) {
$this->connection->executeStatement($sql);
}
$io->success(sprintf('%d COMMENT ON statements appliques.', count($statements)));
return Command::SUCCESS;
}
}
@@ -184,6 +184,8 @@ final class SeedE2ECommand extends Command
'sites.view', 'sites.view',
'sites.manage', 'sites.manage',
'sites.bypass_scope', 'sites.bypass_scope',
'catalog.categories.view',
'catalog.categories.manage',
], ],
], ],
[ [
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Contrat lu par le TimestampableBlamableSubscriber.
*
* Toute entite qui l'implemente voit ses colonnes `created_by` / `updated_by`
* remplies automatiquement avec l'utilisateur authentifie (ou laissees a null
* hors contexte HTTP : CLI, cron, migration).
*
* Le type-hint cible `Symfony\Component\Security\Core\User\UserInterface`
* (deja implementee par App\Module\Core\Domain\Entity\User) pour eviter de
* coupler Shared a Module/Core. La classe concrete est resolue par Doctrine
* via `resolve_target_entities` (cf. config/packages/doctrine.yaml).
*/
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use DateTimeImmutable;
/**
* Contrat lu par le TimestampableBlamableSubscriber.
*
* Toute entite qui l'implemente voit ses colonnes `created_at` / `updated_at`
* remplies automatiquement au prePersist / preUpdate. Le porteur des colonnes
* et des accesseurs est le TimestampableBlamableTrait.
*/
interface TimestampableInterface
{
public function getCreatedAt(): ?DateTimeImmutable;
public function setCreatedAt(DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?DateTimeImmutable;
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Trait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Trait Doctrine qui porte les 4 colonnes Timestampable + Blamable.
*
* Usage : `use TimestampableBlamableTrait;` dans l'entite, +
* `implements TimestampableInterface, BlamableInterface`. Le
* TimestampableBlamableSubscriber remplit les colonnes automatiquement
* au prePersist / preUpdate.
*
* Les Groups Serializer utilisent une convention `default:read` agregee :
* pour exposer les 4 colonnes dans une reponse API d'une entite X, ajouter
* `default:read` au normalizationContext aux cotes du groupe `x:read`.
*/
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable')]
#[Groups(['default:read'])]
private ?DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['default:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
public function getUpdatedBy(): ?UserInterface
{
return $this->updatedBy;
}
public function setUpdatedBy(?UserInterface $user): void
{
$this->updatedBy = $user;
}
}
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Database;
/**
* Catalogue centralise des descriptions SQL (`COMMENT ON TABLE` /
* `COMMENT ON COLUMN`) appliquees aux tables metier de Starseed.
*
* Source unique de verite, utilisee par :
* - `migrations/Version20260528120000.php` : retrofit initial des tables
* pre-existantes (ERP-67).
* - `App\Module\Core\Infrastructure\Console\ApplyColumnCommentsCommand` :
* reapplique les commentaires apres `doctrine:schema:update --force` en
* environnement de test (cf. commentaire de `test-db-setup` dans le
* `makefile`). Doctrine ORM ne conservant pas les commentaires absents
* du mapping PHP, on les rejoue depuis ce catalogue.
*
* Pour ajouter ou modifier un commentaire :
* - Mettre a jour `comments()` ci-dessous.
* - La migration retrofit pose la valeur initiale, la commande la rejoue
* en boucle. Toute future colonne doit etre documentee dans sa propre
* migration (cf. CLAUDE.md regle ABSOLUE n°12) — ce catalogue ne sert
* qu'au retrofit + au workaround schema:update.
*
* Convention : description en francais, ≤ 200 caracteres, semantique du
* champ + contraintes / lien RG si pertinent. La cle speciale `_table` est
* appliquee a la table elle-meme (`COMMENT ON TABLE`).
*/
final class ColumnCommentsCatalog
{
/**
* @return array<string, array<string, string>>
*/
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<string, string>
*/
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<string>
*/
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).'"';
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Listener Doctrine global qui remplit automatiquement les colonnes
* Timestampable + Blamable.
*
* Pattern aligne sur AuditListener (cf.
* src/Module/Core/Infrastructure/Doctrine/AuditListener.php) : declare via
* #[AsDoctrineListener], auto-wire par le DoctrineBundle.
*
* Regle Blamable : si aucun utilisateur n'est authentifie (CLI, cron,
* migration), les FK `created_by` / `updated_by` restent a null. L'affichage
* front gere le libelle « Systeme » pour null.
*/
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final class TimestampableBlamableSubscriber
{
public function __construct(private readonly Security $security) {}
public function prePersist(PrePersistEventArgs $args): void
{
$entity = $args->getObject();
$now = new DateTimeImmutable();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setCreatedAt($now);
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setCreatedBy($user);
$entity->setUpdatedBy($user);
}
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$entity = $args->getObject();
$user = $this->security->getUser();
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable());
}
if ($entity instanceof BlamableInterface && $user instanceof UserInterface) {
$entity->setUpdatedBy($user);
}
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Garde-fou architecture : toute colonne d'une table metier doit porter une
* description SQL (`COMMENT ON COLUMN`).
*
* Postgres stocke la description dans `pg_description`, recuperable via
* `col_description(table_oid, column_position)`. Une colonne sans description
* remonte `NULL`. Le test parcourt `information_schema.columns` filtre sur le
* schema `public` et echoue si une seule colonne metier n'a pas de description.
*
* Tables ignorees :
* - `doctrine_migration_versions` : table system Doctrine, schema fige par la
* librairie.
* - Whitelist `EXCLUDED_TABLES` : doit rester vide ou justifiee — toute entree
* doit avoir un ticket Lesstime ouvert pour le retrofit.
*
* @internal
*/
final class ColumnsHaveSqlCommentTest extends KernelTestCase
{
/**
* Tables system, gerees par Doctrine — leur schema n'est pas notre.
*/
private const EXCLUDED_BUILTINS = [
'doctrine_migration_versions',
];
/**
* Entites mappees uniquement en `when@test` (fixtures techniques pour les
* tests d'integration, jamais en prod). Pas de migration, donc pas de
* lieu naturel pour poser un COMMENT ON COLUMN.
*
* @var list<string>
*/
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<string>
*/
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.');
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Tests\Architecture;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use Doctrine\ORM\Mapping\Entity;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Symfony\Component\Finder\Finder;
use function in_array;
/**
* Garde-fou architecture (niveau L3 de la spec § 2.8.bis).
*
* Scanne toutes les entites Doctrine sous `src/Module/<module>/Domain/Entity/`
* et verifie qu'elles implementent TimestampableInterface ET BlamableInterface
* (via TimestampableBlamableTrait). Empeche tout oubli du pattern sur une
* nouvelle entite metier : la CI passe au rouge.
*
* @internal
*/
final class EntitiesAreTimestampableBlamableTest extends TestCase
{
/**
* Entites explicitement exemptees du pattern.
*
* Au M0, on whiteliste les 4 entites preexistantes du noyau (creees avant
* l'introduction du pattern) : leur retrofit est une decision archi a part
* entiere, hors scope ERP-52.
*
* - User : referentiel d'authentification, createdAt gere manuellement dans
* le constructeur. Retrofit hors scope M0 (cf. HP-9) : impose de trancher
* la recursion Blamable (un User cree par un User) + casse des tests
* existants.
* - Role : referentiel RBAC synchronise via `app:sync-permissions`, pas de
* tracabilite user-driven necessaire.
* - Permission : idem Role (synchronise, pas pilote utilisateur).
* - Site : referentiel admin-managed, a integrer dans un futur module Sites
* v2 (cf. HP-10).
* - CategoryType : referentiel statique (codes de typage des categories),
* pas de besoin de tracabilite user-driven (cree par migration/seed,
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
*
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
*/
private const EXCLUDED = [
User::class,
Role::class,
Permission::class,
Site::class,
CategoryType::class,
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
{
// Garde : chaque entree de la whitelist doit pointer sur une classe
// reelle. Empeche un FQCN errone de masquer silencieusement un oubli.
foreach (self::EXCLUDED as $excluded) {
self::assertTrue(class_exists($excluded), sprintf('Classe whitelistee inexistante : %s', $excluded));
}
$finder = new Finder()
->files()
->in(__DIR__.'/../../src/Module')
->path('Domain/Entity')
->name('*.php')
;
// Garde : si le scan ne trouve rien, le chemin est casse — le test
// deviendrait un faux positif vert. On verifie qu'il a du grain a moudre.
self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?');
foreach ($finder as $file) {
$fqcn = $this->extractFqcn($file->getRealPath());
if (null === $fqcn || in_array($fqcn, self::EXCLUDED, true)) {
continue;
}
$reflection = new ReflectionClass($fqcn);
// On ignore les classes abstraites et tout ce qui n'est pas une
// entite Doctrine (value objects, embeddables non mappes, etc.).
if ($reflection->isAbstract() || [] === $reflection->getAttributes(Entity::class)) {
continue;
}
self::assertTrue(
$reflection->implementsInterface(TimestampableInterface::class)
&& $reflection->implementsInterface(BlamableInterface::class),
sprintf(
'L\'entite %s doit implementer TimestampableInterface ET BlamableInterface '
.'(utiliser TimestampableBlamableTrait). Si c\'est un referentiel statique '
.'justifie, l\'ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
$fqcn,
),
);
}
}
/**
* Extrait le FQCN (namespace + classe) d'un fichier PHP par lecture du
* source, sans charger le fichier.
*/
private function extractFqcn(string $path): ?string
{
$source = file_get_contents($path);
if (false === $source) {
return null;
}
if (
1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch)
|| 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch)
) {
return null;
}
return trim($nsMatch[1]).'\\'.$classMatch[1];
}
}
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Classe de base pour les tests fonctionnels du module Catalog.
*
* Etend la base Core :
* - factories `createCategoryType()` et `createCategory()` pour seeder vite
* les referentiels et les entites metier dans les tests ;
* - helpers d'authentification specifiques au M0 : `createAdminClient()`,
* `createManageClient()`, `createViewClient()` et un helper persona
* `createPersonaClient($label)` simulant les 4 roles MALIO sans permission
* catalog (Bureau / Compta / Commerciale / Usine).
*
* Cleanup : les noms de Category sont prefixes `test_cat_` et les codes de
* CategoryType sont prefixes `TEST_`. Le tearDown purge ces lignes, ainsi
* que les users / roles `test_*` crees par `createUserWithPermission` et
* `createPersonaClient`. Pas de DAMA en local, donc purge manuelle obligatoire.
*
* @internal
*/
abstract class AbstractCatalogApiTestCase extends AbstractApiTestCase
{
protected const string TEST_CATEGORY_PREFIX = 'test_cat_';
protected const string TEST_CATEGORY_TYPE_PREFIX = 'TEST_';
protected const string TEST_USER_PREFIX = 'test_';
protected const string TEST_ROLE_PREFIX = 'test_';
protected function tearDown(): void
{
$this->cleanupCatalogTestData();
parent::tearDown();
}
/**
* Cree un CategoryType de test. Le code est prefixe `TEST_` pour le
* cleanup, suffixe par un nonce aleatoire pour eviter les collisions
* inter-tests.
*/
protected function createCategoryType(?string $code = null, ?string $label = null): CategoryType
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$type = new CategoryType();
$type->setCode($code ?? self::TEST_CATEGORY_TYPE_PREFIX.strtoupper($suffix));
$type->setLabel($label ?? 'Test Type '.$suffix);
$em->persist($type);
$em->flush();
return $type;
}
/**
* Cree une Category de test. Le nom est prefixe `test_cat_` pour le
* cleanup. Si aucun type n'est fourni, un nouveau CategoryType est cree.
* Le flag $deletedAt permet de seeder directement une categorie
* soft-deleted (pour les tests RG-1.08 / RG-1.11).
*/
protected function createCategory(
?string $name = null,
?CategoryType $type = null,
?DateTimeImmutable $deletedAt = null,
): Category {
$em = $this->getEm();
$type ??= $this->createCategoryType();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$category = new Category();
$category->setName($name ?? self::TEST_CATEGORY_PREFIX.$suffix);
$category->setCategoryType($type);
if (null !== $deletedAt) {
$category->setDeletedAt($deletedAt);
}
$em->persist($category);
$em->flush();
return $category;
}
/**
* Client authentifie en tant qu'admin fixture (bypass via isAdmin).
*/
protected function createAdminClient(): Client
{
return $this->authenticatedClient('admin', 'admin');
}
/**
* Client non-admin portant la permission `catalog.categories.manage`.
* Utilise pour prouver qu'un non-admin avec la permission obtient 200 /
* 201 / 204 sur POST / PATCH / DELETE.
*
* @return array{client: Client, credentials: array{username: string, password: string}}
*/
protected function createManageClient(): array
{
$credentials = $this->createUserWithPermission('catalog.categories.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
return ['client' => $client, 'credentials' => $credentials];
}
/**
* Client non-admin portant la permission `catalog.categories.view`.
*/
protected function createViewClient(): Client
{
$credentials = $this->createUserWithPermission('catalog.categories.view');
return $this->authenticatedClient($credentials['username'], $credentials['password']);
}
/**
* Client authentifie en tant qu'un des 4 personas metier MALIO sans
* permission catalog. Les 4 roles (Bureau / Compta / Commerciale / Usine)
* sont seules creees a la volee dans le test, sans aucune permission
* catalog.categories.* attachee. Le user obtient donc systematiquement
* 403 sur tous les endpoints `/api/categories*` et `/api/category_types*`.
*
* Note : ces roles ne sont pas seedes dans AppFixtures (cf. HP-8 de la
* spec M0). Les tests les materialisent juste pour prouver que porter
* un role metier sans la permission catalog donne bien 403.
*/
protected function createPersonaClient(string $personaLabel): Client
{
if (!self::$kernel) {
self::bootKernel();
}
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = self::TEST_USER_PREFIX.strtolower($personaLabel).'_'.$suffix;
$password = 'testpass';
/** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// Role nomme d'apres le persona MALIO, ZERO permission catalog.
$role = new Role(
self::TEST_ROLE_PREFIX.strtolower($personaLabel).'_'.$suffix,
$personaLabel.' (test)',
false,
);
$em->persist($role);
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role);
// Rattachement aux sites pour rester aligne sur createUserWithPermission.
foreach ($em->getRepository(Site::class)->findAll() as $site) {
$user->addSite($site);
}
$em->persist($user);
$em->flush();
$em->clear();
return $this->authenticatedClient($username, $password);
}
/**
* Purge des donnees Catalog crees par les tests.
*
* Strategie : purge complete des tables `category` et `category_type`
* (aucune fixture ne les remplit au M0 — la migration cree les tables
* vides, cf. spec-back § 1 + HP-1). On evite ainsi les pieges de
* cleanup par prefixe quand un test valide le mauvais payload (ex:
* name="" persiste sans matcher le LIKE) et laisse des orphelins
* bloquant le DELETE category_type par FK violation.
*
* Ordre :
* 1. Categories d'abord (FK ON DELETE RESTRICT vers category_type) ;
* 2. CategoryTypes ensuite ;
* 3. Users / Roles `test_*` enfin (FK created_by/updated_by sur
* category est ON DELETE SET NULL, mais on a deja purge category).
*/
private function cleanupCatalogTestData(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Category::class)->execute();
$em->createQuery('DELETE FROM '.CategoryType::class)->execute();
$em->createQuery(
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix'
)->setParameter('prefix', self::TEST_USER_PREFIX.'%')->execute();
$em->createQuery(
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix'
)->setParameter('prefix', self::TEST_ROLE_PREFIX.'%')->execute();
}
}
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use Doctrine\DBAL\Connection;
/**
* Tests Audit : l'attribut `#[Auditable]` porte sur Category, donc chaque
* POST / PATCH / DELETE doit produire une ligne dans `audit_log` via le
* AuditListener + AuditLogWriter (cf. spec audit-log.md).
*
* Verifications :
* - une ligne `entity_type='catalog.Category'` apparait apres chaque
* operation HTTP authentifiee comme admin ;
* - l'action est `create` / `update` (le soft delete est trace comme
* `update` puisque c'est un UPDATE Doctrine, cf. spec § 6.1) ;
* - `performed_by` est le username du user authentifie ;
* - `changes` est non vide (snapshot complet pour insert, diff pour update).
*
* Lecture via la connexion DBAL `audit` (pattern de AuditLogApiTest).
*
* @internal
*/
final class CategoryAuditTest extends AbstractCatalogApiTestCase
{
private const string ENTITY_TYPE = 'catalog.Category';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testAuditLogOnCreate(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_create',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
$createdId = (string) $response->toArray()['id'];
$rows = $this->fetchAuditRows($createdId, 'create');
self::assertCount(1, $rows, 'Un audit_log "create" doit etre genere apres POST.');
self::assertSame('admin', $rows[0]['performed_by']);
$changes = $this->decodeChanges($rows[0]['changes']);
// Snapshot complet : au moins le name doit etre dedans.
self::assertArrayHasKey('name', $changes);
self::assertSame(
self::TEST_CATEGORY_PREFIX.'audit_create',
$changes['name'] ?? null,
'Le snapshot create doit porter le name persiste.',
);
}
public function testAuditLogOnUpdate(): void
{
$category = $this->createCategory();
$client = $this->createAdminClient();
$client->request('PATCH', '/api/categories/'.$category->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'audit_patched'],
]);
self::assertResponseIsSuccessful();
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "update" doit etre genere apres PATCH.');
// On prend la ligne la plus recente.
$latest = $rows[0];
self::assertSame('admin', $latest['performed_by']);
$changes = $this->decodeChanges($latest['changes']);
// L'update doit contenir la diff sur `name` : {old: ..., new: 'audit_patched'}.
self::assertArrayHasKey('name', $changes);
self::assertIsArray($changes['name']);
self::assertArrayHasKey('new', $changes['name']);
self::assertSame(self::TEST_CATEGORY_PREFIX.'audit_patched', $changes['name']['new']);
}
public function testAuditLogOnSoftDelete(): void
{
$category = $this->createCategory();
$client = $this->createAdminClient();
$client->request('DELETE', '/api/categories/'.$category->getId());
self::assertResponseStatusCodeSame(204);
// Le soft delete = UPDATE Doctrine -> action 'update' en audit, avec
// la diff sur deletedAt (RG-1.12 + spec § 6.1).
$rows = $this->fetchAuditRows((string) $category->getId(), 'update');
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log doit tracer le soft delete (en tant qu\'update).');
$latest = $rows[0];
$changes = $this->decodeChanges($latest['changes']);
self::assertArrayHasKey('deletedAt', $changes, 'La diff doit contenir deletedAt.');
self::assertIsArray($changes['deletedAt']);
self::assertArrayHasKey('new', $changes['deletedAt']);
self::assertNotNull(
$changes['deletedAt']['new'],
'deletedAt.new doit etre rempli (timestamp ISO ou tableau Doctrine).',
);
}
public function testAuditLogPerformerCarriesAuthenticatedUsername(): void
{
// Manage user (non-admin) : prouve que performed_by suit l'auth, pas
// un mock hardcode "admin".
$type = $this->createCategoryType();
$manage = $this->createManageClient();
$client = $manage['client'];
$managerUsername = $manage['credentials']['username'];
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'audit_manager',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
$createdId = (string) $response->toArray()['id'];
$rows = $this->fetchAuditRows($createdId, 'create');
self::assertCount(1, $rows);
self::assertSame(
$managerUsername,
$rows[0]['performed_by'],
'performed_by doit refleter le user authentifie (pas l\'admin par defaut).',
);
}
/**
* @param Category::class lookups via entity_id + action
*
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string, performed_by: string}>
*/
private function fetchAuditRows(string $entityId, string $action): array
{
/** @var list<array<string, string>> $rows */
return $this->auditConnection->fetchAllAssociative(
'SELECT id, entity_type, entity_id, action, changes, performed_by '
.'FROM audit_log '
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
.'ORDER BY performed_at DESC',
[
'type' => self::ENTITY_TYPE,
'id' => $entityId,
'action' => $action,
],
);
}
/**
* @return array<string, mixed>
*/
private function decodeChanges(string $raw): array
{
/** @var array<string, mixed> $decoded */
return json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use DateTimeImmutable;
/**
* Tests RG-1.12 / RG-1.13 : suppression et soft-delete de Category.
*
* - RG-1.12 : DELETE pose `deletedAt` au lieu d'un hard delete (la ligne
* reste en BDD avec `deleted_at IS NOT NULL`) et renvoie 204.
* - RG-1.13 : PATCH ne peut pas ecrire `deletedAt` (groupe `category:write`
* l'exclut), donc une tentative d'override est silencieusement ignoree.
* - Provider sur PATCH/DELETE : 404 si la categorie cible est deja
* soft-deleted (cf. CategoryProvider, ticket 0.3).
*
* @internal
*/
final class CategoryDeleteTest extends AbstractCatalogApiTestCase
{
public function testDeleteReturns204AndPersistsSoftDelete(): void
{
$category = $this->createCategory();
$categoryId = $category->getId();
$client = $this->createAdminClient();
$client->request('DELETE', '/api/categories/'.$categoryId);
self::assertResponseStatusCodeSame(204);
// RG-1.12 : la ligne doit toujours exister en BDD avec deletedAt non null.
$em = $this->getEm();
$em->clear();
/** @var null|Category $reloaded */
$reloaded = $em->getRepository(Category::class)->find($categoryId);
self::assertNotNull($reloaded, 'La ligne ne doit PAS etre supprimee physiquement (soft delete).');
self::assertNotNull($reloaded->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
}
public function testPatchCannotSetDeletedAt(): void
{
// RG-1.13 : le groupe `category:write` ne contient pas `deletedAt`,
// donc une tentative d'override doit etre silencieusement ignoree.
$category = $this->createCategory();
$categoryId = $category->getId();
self::assertNull($category->getDeletedAt());
$client = $this->createAdminClient();
$client->request('PATCH', '/api/categories/'.$categoryId, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'deletedAt' => new DateTimeImmutable()->format(DateTimeImmutable::ATOM),
],
]);
// Le code precis depend d'API Platform : 200 (champ ignore) ou 400.
// Quoi qu'il arrive, deletedAt en BDD doit rester null.
$em = $this->getEm();
$em->clear();
/** @var Category $reloaded */
$reloaded = $em->getRepository(Category::class)->find($categoryId);
self::assertNull(
$reloaded->getDeletedAt(),
'PATCH ne doit JAMAIS pouvoir ecrire deletedAt (RG-1.13).',
);
}
public function testPatchOnSoftDeletedReturns404(): void
{
// Le Provider est cable sur PATCH (cf. Category::class § Patch). Une
// categorie deja soft-deletee n'est pas visible en lecture, donc le
// PATCH doit recevoir 404 (route resolved by API Platform retournee
// par le provider) — comme un Get unitaire (RG-1.11 etendue).
$category = $this->createCategory(
null,
null,
new DateTimeImmutable(),
);
$client = $this->createAdminClient();
$client->request('PATCH', '/api/categories/'.$category->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'try_patch'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testDeleteOnSoftDeletedReturns404(): void
{
// Idem PATCH : un DELETE sur une categorie deja soft-deletee est un
// 404 (le Provider la masque), pas une operation idempotente silencieuse.
$category = $this->createCategory(
null,
null,
new DateTimeImmutable(),
);
$client = $this->createAdminClient();
$client->request('DELETE', '/api/categories/'.$category->getId());
self::assertResponseStatusCodeSame(404);
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use DateTimeImmutable;
/**
* Tests RG-1.11 : GET /api/categories/{id}.
*
* - Category soft-deleted sans flag → 404 ;
* - Category soft-deleted avec `?includeDeleted=true` → 200 ;
* - Category inexistante → 404.
*
* @internal
*/
final class CategoryGetTest extends AbstractCatalogApiTestCase
{
public function testGetActiveCategoryReturns200(): void
{
$category = $this->createCategory();
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories/'.$category->getId());
self::assertSame(200, $response->getStatusCode());
self::assertSame($category->getId(), $response->toArray()['id']);
}
public function testGetSoftDeletedReturns404(): void
{
$category = $this->createCategory(
null,
null,
new DateTimeImmutable(),
);
$client = $this->createAdminClient();
$client->request('GET', '/api/categories/'.$category->getId());
self::assertResponseStatusCodeSame(404);
}
public function testGetSoftDeletedWithFlagReturns200(): void
{
$category = $this->createCategory(
null,
null,
new DateTimeImmutable(),
);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories/'.$category->getId().'?includeDeleted=true');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertSame($category->getId(), $data['id']);
self::assertNotNull($data['deletedAt'], 'Le champ deletedAt doit etre expose dans la reponse.');
}
public function testGetNonExistentReturns404(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/categories/9999999');
self::assertResponseStatusCodeSame(404);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use DateTimeImmutable;
/**
* Tests RG-1.08 / RG-1.09 / RG-1.10 : comportement de GET /api/categories.
*
* - RG-1.08 : par defaut, les categories soft-deleted sont exclues ;
* - RG-1.09 : `?includeDeleted=true` inclut les soft-deleted ;
* - RG-1.10 : tri par defaut `name ASC` cote serveur.
*
* @internal
*/
final class CategoryListTest extends AbstractCatalogApiTestCase
{
public function testListExcludesSoftDeletedByDefault(): void
{
$type = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha', $type);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'beta', $type);
$this->createCategory(
self::TEST_CATEGORY_PREFIX.'gone',
$type,
new DateTimeImmutable(),
);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
$members = $data['member'];
// On filtre sur le prefix test_cat_ pour ne pas etre pollue par
// d'autres entrees presentes en base (fixtures, autres tests).
$names = array_values(array_filter(
array_map(fn (array $m): string => $m['name'], $members),
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
));
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha', $names);
self::assertContains(self::TEST_CATEGORY_PREFIX.'beta', $names);
self::assertNotContains(
self::TEST_CATEGORY_PREFIX.'gone',
$names,
'Les categories soft-deleted doivent etre exclues par defaut (RG-1.08).',
);
}
public function testIncludeDeletedFlagSurfacesSoftDeleted(): void
{
$type = $this->createCategoryType();
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha2', $type);
$this->createCategory(
self::TEST_CATEGORY_PREFIX.'gone2',
$type,
new DateTimeImmutable(),
);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?includeDeleted=true');
self::assertSame(200, $response->getStatusCode());
$names = array_values(array_filter(
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
));
self::assertContains(self::TEST_CATEGORY_PREFIX.'alpha2', $names);
self::assertContains(
self::TEST_CATEGORY_PREFIX.'gone2',
$names,
'?includeDeleted=true doit faire apparaitre les soft-deleted (RG-1.09).',
);
}
public function testDefaultSortIsNameAsc(): void
{
$type = $this->createCategoryType();
// Insertion volontairement dans le desordre pour prouver le tri.
$this->createCategory(self::TEST_CATEGORY_PREFIX.'zorro', $type);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'alpha_sort', $type);
$this->createCategory(self::TEST_CATEGORY_PREFIX.'mid', $type);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories');
self::assertSame(200, $response->getStatusCode());
$names = array_values(array_filter(
array_map(fn (array $m): string => $m['name'], $response->toArray()['member']),
fn (string $n): bool => str_starts_with($n, self::TEST_CATEGORY_PREFIX),
));
// Verifie que la sous-liste de nos 3 entrees est triee croissante.
$expectedSubset = [
self::TEST_CATEGORY_PREFIX.'alpha_sort',
self::TEST_CATEGORY_PREFIX.'mid',
self::TEST_CATEGORY_PREFIX.'zorro',
];
$filtered = array_values(array_intersect($names, $expectedSubset));
self::assertSame(
$expectedSubset,
$filtered,
'Les categories doivent etre retournees triees par name ASC (RG-1.10).',
);
}
}
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* Tests RG-1.01 : permissions RBAC catalog.categories.view / manage.
*
* Verifie que :
* - les 4 personas metier MALIO (Bureau / Compta / Commerciale / Usine) sans
* permission catalog.categories.* obtiennent 403 sur tous les verbes des
* endpoints `/api/categories*` et `/api/category_types*` ;
* - un utilisateur anonyme (sans JWT) obtient 401 ;
* - l'admin (bypass via isAdmin) obtient le code attendu (200 / 201 / 204).
*
* @internal
*/
final class CategoryPermissionsTest extends AbstractCatalogApiTestCase
{
// ============ /api/categories — collection ============
#[DataProvider('personaProvider')]
public function testPersonaWithoutCatalogPermissionGets403OnGetCollection(string $personaLabel): void
{
$client = $this->createPersonaClient($personaLabel);
$client->request('GET', '/api/categories');
self::assertResponseStatusCodeSame(403);
}
public function testAnonymousGets401OnGetCollection(): void
{
$client = self::createClient();
$client->request('GET', '/api/categories');
self::assertResponseStatusCodeSame(401);
}
public function testAdminGets200OnGetCollection(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/categories');
self::assertResponseStatusCodeSame(200);
}
public function testUserWithViewPermissionGets200OnGetCollection(): void
{
$client = $this->createViewClient();
$client->request('GET', '/api/categories');
self::assertResponseStatusCodeSame(200);
}
// ============ /api/categories — POST ============
#[DataProvider('personaProvider')]
public function testPersonaWithoutManagePermissionGets403OnPost(string $personaLabel): void
{
$type = $this->createCategoryType();
$client = $this->createPersonaClient($personaLabel);
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'forbidden',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(403);
}
public function testAnonymousGets401OnPost(): void
{
$type = $this->createCategoryType();
$client = self::createClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'anon',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(401);
}
public function testAdminGets201OnPost(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'admin_create',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testUserWithOnlyViewPermissionGets403OnPost(): void
{
// Prouve qu'avoir `view` ne suffit pas a POSTer (manage requis).
$type = $this->createCategoryType();
$client = $this->createViewClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'view_only',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(403);
}
// ============ /api/categories/{id} — PATCH ============
#[DataProvider('personaProvider')]
public function testPersonaWithoutManagePermissionGets403OnPatch(string $personaLabel): void
{
$category = $this->createCategory();
$client = $this->createPersonaClient($personaLabel);
$client->request('PATCH', '/api/categories/'.$category->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'patched'],
]);
self::assertResponseStatusCodeSame(403);
}
// ============ /api/categories/{id} — DELETE ============
#[DataProvider('personaProvider')]
public function testPersonaWithoutManagePermissionGets403OnDelete(string $personaLabel): void
{
$category = $this->createCategory();
$client = $this->createPersonaClient($personaLabel);
$client->request('DELETE', '/api/categories/'.$category->getId());
self::assertResponseStatusCodeSame(403);
}
public function testAdminGets204OnDelete(): void
{
$category = $this->createCategory();
$client = $this->createAdminClient();
$client->request('DELETE', '/api/categories/'.$category->getId());
self::assertResponseStatusCodeSame(204);
}
// ============ /api/category_types — referentiel ============
#[DataProvider('personaProvider')]
public function testPersonaWithoutCatalogPermissionGets403OnCategoryTypes(string $personaLabel): void
{
$client = $this->createPersonaClient($personaLabel);
$client->request('GET', '/api/category_types');
self::assertResponseStatusCodeSame(403);
}
/**
* @return iterable<string, array{string}>
*/
public static function personaProvider(): iterable
{
yield 'Bureau' => ['Bureau'];
yield 'Compta' => ['Compta'];
yield 'Commerciale' => ['Commerciale'];
yield 'Usine' => ['Usine'];
}
public function testAnonymousGets401OnCategoryTypes(): void
{
$client = self::createClient();
$client->request('GET', '/api/category_types');
self::assertResponseStatusCodeSame(401);
}
public function testAdminGets200OnCategoryTypes(): void
{
$client = $this->createAdminClient();
$client->request('GET', '/api/category_types');
self::assertResponseStatusCodeSame(200);
}
public function testUserWithViewPermissionGets200OnCategoryTypes(): void
{
// Le referentiel reutilise la meme permission catalog.categories.view.
$client = $this->createViewClient();
$client->request('GET', '/api/category_types');
self::assertResponseStatusCodeSame(200);
}
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
/**
* Tests RG-1.15 / RG-1.16 : le TimestampableBlamableSubscriber doit remplir
* automatiquement les 4 colonnes au prePersist (RG-1.15) et au preUpdate
* (RG-1.16), sans qu'aucun champ ne soit modifiable par l'API client.
*
* - POST authentifie : createdAt = updatedAt = now, createdBy = updatedBy = user
* - Persist hors HTTP (console context) : dates remplies, blame null
* - PATCH par un user different : updatedAt + updatedBy changent, createdAt /
* createdBy restent figes
* - DELETE : deletedAt rempli ET updatedAt + updatedBy mis a jour (UPDATE
* Doctrine declenche le subscriber)
*
* @internal
*/
final class CategoryTimestampableBlamableTest extends AbstractCatalogApiTestCase
{
public function testCreatedByAdminOnPost(): void
{
$type = $this->createCategoryType();
/** @var User $admin */
$admin = $this->getEm()->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertNotNull($admin);
$adminId = $admin->getId();
$before = new DateTimeImmutable();
// Petit decalage pour absorber les arrondis a la seconde de Postgres.
sleep(1);
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_admin',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
$createdId = $response->toArray()['id'];
$em = $this->getEm();
$em->clear();
/** @var Category $reloaded */
$reloaded = $em->getRepository(Category::class)->find($createdId);
// RG-1.15 — dates remplies, egales au prePersist
self::assertNotNull($reloaded->getCreatedAt());
self::assertNotNull($reloaded->getUpdatedAt());
self::assertGreaterThanOrEqual(
$before->getTimestamp(),
$reloaded->getCreatedAt()->getTimestamp(),
'createdAt doit etre post-test-start.',
);
self::assertSame(
$reloaded->getCreatedAt()->getTimestamp(),
$reloaded->getUpdatedAt()->getTimestamp(),
'Au POST, createdAt et updatedAt doivent etre identiques.',
);
// RG-1.15 — blame remplis avec le user authentifie (admin)
self::assertNotNull($reloaded->getCreatedBy());
self::assertNotNull($reloaded->getUpdatedBy());
self::assertSame($adminId, $reloaded->getCreatedBy()->getId());
self::assertSame($adminId, $reloaded->getUpdatedBy()->getId());
}
public function testCreatedByNullInConsoleContext(): void
{
// RG-1.15 : persist sans contexte HTTP -> Security::getUser() retourne
// null -> blame reste null, mais les dates restent remplies.
// On utilise la factory createCategory() qui fait un persist Doctrine
// direct (pas via le client HTTP).
$category = $this->createCategory(self::TEST_CATEGORY_PREFIX.'console');
$em = $this->getEm();
$em->clear();
/** @var Category $reloaded */
$reloaded = $em->getRepository(Category::class)->find($category->getId());
// Dates remplies par le subscriber.
self::assertNotNull($reloaded->getCreatedAt());
self::assertNotNull($reloaded->getUpdatedAt());
// Blame null (pas de Security::getUser() dispo hors HTTP).
self::assertNull(
$reloaded->getCreatedBy(),
'createdBy doit etre null hors contexte HTTP (RG-1.15 fallback).',
);
self::assertNull($reloaded->getUpdatedBy());
}
public function testPatchUpdatesUpdatedFieldsOnly(): void
{
// Etape 1 : creation par admin pour figer createdBy=admin.
$type = $this->createCategoryType();
$adminClient = $this->createAdminClient();
$response = $adminClient->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_patch',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
$createdId = $response->toArray()['id'];
// Snapshot des valeurs initiales pour comparaison apres PATCH.
$em = $this->getEm();
$em->clear();
/** @var Category $initial */
$initial = $em->getRepository(Category::class)->find($createdId);
$initialCreatedAt = $initial->getCreatedAt();
$initialUpdatedAt = $initial->getUpdatedAt();
$initialCreatedById = $initial->getCreatedBy()->getId();
// Decalage temporel suffisant pour que la precision PG (seconde)
// capte un updatedAt different.
sleep(1);
// Etape 2 : PATCH par un autre user (manager non-admin) — simule "bob".
$manage = $this->createManageClient();
$bobClient = $manage['client'];
/** @var User $bob */
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
$bobId = $bob->getId();
self::assertNotSame($initialCreatedById, $bobId, 'Le test exige deux users distincts.');
$bobClient->request('PATCH', '/api/categories/'.$createdId, [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => self::TEST_CATEGORY_PREFIX.'tsb_patched_by_bob'],
]);
self::assertResponseIsSuccessful();
// Etape 3 : verifications RG-1.16
$em = $this->getEm();
$em->clear();
/** @var Category $patched */
$patched = $em->getRepository(Category::class)->find($createdId);
// createdAt / createdBy figes
self::assertSame(
$initialCreatedAt->getTimestamp(),
$patched->getCreatedAt()->getTimestamp(),
'createdAt doit etre fige au PATCH (RG-1.16).',
);
self::assertSame(
$initialCreatedById,
$patched->getCreatedBy()->getId(),
'createdBy doit etre fige au PATCH (RG-1.16).',
);
// updatedAt / updatedBy mis a jour
self::assertGreaterThan(
$initialUpdatedAt->getTimestamp(),
$patched->getUpdatedAt()->getTimestamp(),
'updatedAt doit avancer apres PATCH (RG-1.16).',
);
self::assertSame(
$bobId,
$patched->getUpdatedBy()->getId(),
'updatedBy doit refleter le user PATCH (RG-1.16).',
);
}
public function testSoftDeleteAlsoUpdatesUpdatedFields(): void
{
// RG-1.16 : le soft delete est un UPDATE Doctrine, donc le subscriber
// doit aussi avancer updatedAt et updatedBy en plus de poser deletedAt.
$type = $this->createCategoryType();
$adminClient = $this->createAdminClient();
$response = $adminClient->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'tsb_delete',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
$createdId = $response->toArray()['id'];
$em = $this->getEm();
$em->clear();
/** @var Category $initial */
$initial = $em->getRepository(Category::class)->find($createdId);
$initialUpdatedAt = $initial->getUpdatedAt();
sleep(1);
// Soft delete par un manager non-admin.
$manage = $this->createManageClient();
$bobClient = $manage['client'];
/** @var User $bob */
$bob = $this->getEm()->getRepository(User::class)->findOneBy(['username' => $manage['credentials']['username']]);
$bobId = $bob->getId();
$bobClient->request('DELETE', '/api/categories/'.$createdId);
self::assertResponseStatusCodeSame(204);
$em = $this->getEm();
$em->clear();
/** @var Category $deleted */
$deleted = $em->getRepository(Category::class)->find($createdId);
// deletedAt rempli
self::assertNotNull($deleted->getDeletedAt(), 'deletedAt doit etre rempli apres DELETE.');
// updatedAt avance, updatedBy = bob
self::assertGreaterThan(
$initialUpdatedAt->getTimestamp(),
$deleted->getUpdatedAt()->getTimestamp(),
'updatedAt doit avancer au soft delete (RG-1.16).',
);
self::assertSame(
$bobId,
$deleted->getUpdatedBy()->getId(),
'updatedBy doit refleter l\'auteur du soft delete (RG-1.16).',
);
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Tests RG-1.07 : unicite case-insensitive de (LOWER(name), category_type_id)
* parmi les categories non soft-deleted. L'index Postgres partiel
* `uq_category_name_type_active` est traduit en 409 Conflict par le
* CategoryProcessor.
*
* Cas couverts :
* - doublon strict (meme name + meme type) → 409 ;
* - doublon case-insensitive (Vis / vis sur meme type) → 409 ;
* - meme name sur 2 types differents → les deux passent (pas de doublon) ;
* - recreation apres soft delete → 201 (l'index partiel libere le couple).
*
* @internal
*/
final class CategoryUniqueTest extends AbstractCatalogApiTestCase
{
public function testDuplicateNameSameTypeReturns409(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
// 1er POST : doit reussir.
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
// 2eme POST : meme name + meme type → doublon strict.
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'unique',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(409, $response->getStatusCode());
// Message attendu par la spec RG-1.07.
$payload = $response->toArray(false);
$description = $payload['description'] ?? $payload['detail'] ?? $payload['hydra:description'] ?? '';
self::assertStringContainsString(
'existe déjà pour ce type',
$description,
'Le message d\'erreur 409 doit citer la spec ("existe deja pour ce type").',
);
}
public function testDuplicateNameCaseInsensitiveReturns409(): void
{
// RG-1.07 : la collision est case-insensitive (index sur LOWER(name)).
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'Vis',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
// Meme prefix mais variation de casse → meme LOWER → collision.
'name' => self::TEST_CATEGORY_PREFIX.'VIS',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(409, $response->getStatusCode());
}
public function testSameNameDifferentTypeAllowed(): void
{
// RG-1.07 : la contrainte est SUR (name, type), pas sur name seul.
// Le meme nom doit etre acceptable sur deux types differents.
$type1 = $this->createCategoryType();
$type2 = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryType' => '/api/category_types/'.$type1->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'shared',
'categoryType' => '/api/category_types/'.$type2->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testRecreateAfterSoftDeleteAllowed(): void
{
// RG-1.07 : l'index Postgres est partiel (WHERE deleted_at IS NULL).
// Apres un soft delete, le couple (name, type) est libere et un
// nouveau POST identique doit reussir.
$type = $this->createCategoryType();
$client = $this->createAdminClient();
// 1) creation
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertSame(201, $response->getStatusCode());
$created = $response->toArray();
// 2) soft delete
$client->request('DELETE', '/api/categories/'.$created['id']);
self::assertResponseStatusCodeSame(204);
// 3) recreation : meme name + meme type → autorise (couple libere).
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'recreate',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\Category;
/**
* Tests des regles de validation POST/PATCH sur Category :
* - RG-1.02 : `name` obligatoire (NotBlank) ;
* - RG-1.03 : `name` trim cote serveur via CategoryProcessor ;
* - RG-1.04 : `name` longueur 2..120 (Length) ;
* - RG-1.05 : `categoryType` obligatoire ;
* - RG-1.06 : `categoryType` doit pointer un type existant.
*
* @internal
*/
final class CategoryValidationTest extends AbstractCatalogApiTestCase
{
// ============ RG-1.02 — name NotBlank ============
public function testNameRequiredReturns422(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'categoryType' => '/api/category_types/'.$type->getId(),
// name absent
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testNameEmptyStringReturns422(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => '',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testNameWhitespaceOnlyReturns422(): void
{
// Le Processor trim avant la validation : " " devient "" -> NotBlank
// doit declencher 422 (RG-1.02 combinee a RG-1.03).
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => ' ',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(422);
}
// ============ RG-1.03 — name trim cote serveur ============
public function testNameIsTrimmedOnCreate(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$payloadName = ' '.self::TEST_CATEGORY_PREFIX.'trim ';
$expected = trim($payloadName);
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => $payloadName,
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
// Verification cote base : la valeur stockee est trimee.
$em = $this->getEm();
$em->clear();
$stored = $em->getRepository(Category::class)->findOneBy(['name' => $expected]);
self::assertNotNull($stored, 'La categorie trimee doit etre persistee sous "'.$expected.'"');
self::assertSame($expected, $stored->getName());
}
// ============ RG-1.04 — longueur 2..120 ============
public function testNameTooShortReturns422(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'A',
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testNameTooLongReturns422(): void
{
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => str_repeat('a', 121),
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testNameAtMaxLengthIs201(): void
{
// Borne haute : 120 caracteres doit passer (l'index est sur LOWER, name
// est unique en collision avec d'autres tests donc on prefixe la marque
// test_cat_ pour le cleanup et completons jusqu'a 120 caracteres).
$prefix = self::TEST_CATEGORY_PREFIX;
$name = $prefix.str_repeat('z', 120 - strlen($prefix));
self::assertSame(120, strlen($name));
$type = $this->createCategoryType();
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => $name,
'categoryType' => '/api/category_types/'.$type->getId(),
],
]);
self::assertResponseStatusCodeSame(201);
}
// ============ RG-1.05 — categoryType obligatoire ============
public function testCategoryTypeRequiredReturns422(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'no_type',
// categoryType absent
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCategoryTypeNullIsRejected(): void
{
// `categoryType: null` echoue a la deserialization IRI (API Platform
// renvoie 400) bien avant la validation Assert\NotNull. La spec § 4.3
// accepte les deux : on assert le contrat fort "ne passe pas en BDD".
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'null_type',
'categoryType' => null,
],
]);
self::assertContains(
$response->getStatusCode(),
[400, 422],
'categoryType=null doit etre rejete (400 deserialization ou 422 validation).',
);
}
// ============ RG-1.06 — categoryType doit exister ============
public function testCategoryTypeMustExistReturns4xx(): void
{
// IRI vers un id qui n'existe pas. API Platform peut renvoyer 400
// (resolution IRI echouee) ou 422 (validation NotNull declenchee).
// La spec § 4.3 accepte les deux : on assert le contrat "ne passe pas".
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/categories', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => self::TEST_CATEGORY_PREFIX.'ghost_type',
'categoryType' => '/api/category_types/9999999',
],
]);
self::assertContains(
$response->getStatusCode(),
[400, 404, 422],
'IRI categoryType inexistante doit etre rejetee (400/404/422 selon API Platform).',
);
}
}
@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Tests\Shared\Infrastructure\Doctrine;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du TimestampableBlamableSubscriber.
*
* On exerce directement prePersist / preUpdate avec un EntityManager et une
* Security stubbes — aucun boot de kernel, aucun acces BDD. Les entites de test
* sont des fixtures internes (cf. bas de fichier).
*
* @internal
*/
final class TimestampableBlamableSubscriberTest extends TestCase
{
public function testPrePersistWithUser(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity));
// Les 4 colonnes sont remplies : dates posees, blame = user courant.
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
self::assertSame($entity->getCreatedAt(), $entity->getUpdatedAt());
self::assertSame($user, $entity->getCreatedBy());
self::assertSame($user, $entity->getUpdatedBy());
}
public function testPrePersistWithoutUser(): void
{
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning(null));
$entity = new FullAuditableFixture();
$subscriber->prePersist($this->prePersistArgs($entity));
// Hors contexte HTTP (CLI / cron) : dates remplies, blame laisse a null.
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
self::assertNull($entity->getCreatedBy());
self::assertNull($entity->getUpdatedBy());
}
public function testPreUpdate(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
// On simule une entite deja persistee : createdAt fige dans le passe,
// createdBy positionne par une creation anterieure.
$createdAt = new DateTimeImmutable('2020-01-01 10:00:00');
$entity = new FullAuditableFixture();
$entity->setCreatedAt($createdAt);
$entity->setUpdatedAt($createdAt);
$subscriber->preUpdate($this->preUpdateArgs($entity));
// updatedAt avance, createdAt reste fige, updatedBy = user courant.
self::assertSame($createdAt, $entity->getCreatedAt());
self::assertGreaterThan($createdAt, $entity->getUpdatedAt());
self::assertSame($user, $entity->getUpdatedBy());
}
public function testPartialEntityTimestampableOnly(): void
{
$user = $this->createStub(UserInterface::class);
$subscriber = new TimestampableBlamableSubscriber($this->securityReturning($user));
$entity = new TimestampableOnlyFixture();
// Entite Timestampable mais NON Blamable : seules les dates sont posees,
// aucun appel de blame (et aucune erreur).
$subscriber->prePersist($this->prePersistArgs($entity));
self::assertInstanceOf(DateTimeImmutable::class, $entity->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $entity->getUpdatedAt());
}
/**
* Security stubbee renvoyant l'utilisateur fourni (ou null).
*/
private function securityReturning(?UserInterface $user): Security
{
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
return $security;
}
private function prePersistArgs(object $entity): PrePersistEventArgs
{
return new PrePersistEventArgs($entity, $this->createStub(EntityManagerInterface::class));
}
private function preUpdateArgs(object $entity): PreUpdateEventArgs
{
$changeSet = [];
return new PreUpdateEventArgs($entity, $this->createStub(EntityManagerInterface::class), $changeSet);
}
}
/**
* Fixture interne : entite metier complete (Timestampable + Blamable) via le
* Trait reel teste.
*
* @internal
*/
final class FullAuditableFixture implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
}
/**
* Fixture interne : entite Timestampable seule (sans Blamable), pour verifier
* la dissociation des deux contrats par le Subscriber.
*
* @internal
*/
final class TimestampableOnlyFixture implements TimestampableInterface
{
private ?DateTimeImmutable $createdAt = null;
private ?DateTimeImmutable $updatedAt = null;
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}