diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 7534f0c..e21a688 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -74,3 +74,53 @@ Exemple : pour qu'`User.profile` soit embarque au lieu d'un lien IRI sous le gro ## PostgreSQL - Noms de colonnes toujours en **minuscules** dans le SQL brut (commun a tous les projets MALIO) + +## Migrations Doctrine + +### Documentation SQL obligatoire (`COMMENT ON COLUMN`) + +**Toute migration qui cree ou modifie une colonne d'une table metier doit poser un `COMMENT ON COLUMN` decrivant le champ.** La description est stockee dans `pg_description` et visible dans tous les outils d'admin BDD (DBeaver, DataGrip, pgAdmin), sans avoir a lire les annotations PHP. + +**Format de la description** : +- En francais +- ≤ 200 caracteres +- Semantique du champ — contraintes / lien RG si pertinent +- Pour les colonnes d'identifiant ou FK, mentionner la cible + +Exemples : + +```php +// Migration : creation d'une colonne avec son commentaire dans la meme migration +$this->addSql("ALTER TABLE client ADD COLUMN siren VARCHAR(9) DEFAULT NULL"); +$this->addSql("COMMENT ON COLUMN client.siren IS 'SIREN (9 chiffres) — identifiant legal entreprise. Unique parmi non-archives (RG-1.15).'"); + +// Cas FK : preciser la cible +$this->addSql("COMMENT ON COLUMN client.legal_form_id IS 'Reference forme juridique (SARL, SAS, SA...) — FK -> legal_form.id, ON DELETE RESTRICT.'"); + +// Cas booleen : preciser le sens et la valeur par defaut +$this->addSql("COMMENT ON COLUMN user.is_admin IS 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.'"); + +// Bonus : decrire la table elle-meme +$this->addSql("COMMENT ON TABLE client IS 'Repertoire clients (M1 Commercial) — entites archivables.'"); +``` + +### Helper Timestampable/Blamable + +Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` ajoutees par `TimestampableBlamableTrait` recoivent une description **standardisee** via le helper centralise pour eviter la duplication. Helper a creer ou appeler : + +```php +// Dans la migration, apres avoir ajoute les 4 colonnes : +$this->addStandardTimestampableBlamableComments($schema, 'client'); +``` + +L'implementation du helper applique : +- `created_at` : « Horodatage de creation de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). » +- `updated_at` : « Horodatage de derniere modification de la ligne (UTC, rempli automatiquement par TimestampableBlamableSubscriber). » +- `created_by` : « ID de l'utilisateur ayant cree la ligne — null pour les creations hors HTTP (CLI, migration, fixture). FK -> user.id, ON DELETE SET NULL. » +- `updated_by` : « ID de l'utilisateur ayant modifie la ligne en dernier — null pour les modifications hors HTTP. FK -> user.id, ON DELETE SET NULL. » + +### Garde-fou architecture + +`tests/Architecture/ColumnsHaveSqlCommentTest` parcourt `information_schema.columns` filtre sur le schema `public` et echoue si **une seule colonne** n'a pas de `col_description`. Seules les tables system (`doctrine_migration_versions`) et la whitelist `EXCLUDED_TABLES` explicite (commentaire de justification + ticket Lesstime ouvert pour le retrofit) sont tolerees. + +Conclusion : si tu crees une colonne sans poser son `COMMENT ON COLUMN`, `make test` casse en CI. diff --git a/.gitea/workflows/pull-request.yml b/.gitea/workflows/pull-request.yml index c0f9425..56062ab 100644 --- a/.gitea/workflows/pull-request.yml +++ b/.gitea/workflows/pull-request.yml @@ -84,6 +84,9 @@ jobs: php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction php bin/console doctrine:migrations:migrate --env=test --no-interaction php bin/console doctrine:schema:update --env=test --force --no-interaction + # Rejoue le catalogue COMMENT ON apres schema:update (cf. ERP-67) : + # schema:update drop les commentaires des tables managees par l'ORM. + php bin/console app:apply-column-comments --env=test --no-interaction php bin/console doctrine:fixtures:load --env=test --no-interaction php bin/console app:sync-permissions --env=test --no-interaction 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" diff --git a/CLAUDE.md b/CLAUDE.md index 137720e..df64645 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md 9. **Jamais commit sans demande explicite** de l'utilisateur ; jamais force push sans confirmation. 10. **Jamais mentionner Claude, Anthropic ou une IA** dans un commit (message, titre, body, footer, trailer) ou une PR (titre, description). Pas de `Co-Authored-By: Claude`, pas de `Generated with Claude Code`, pas de `🤖`, pas d'emoji robot, rien. Les commits sont signes par l'utilisateur uniquement. 11. **Migrations d'initialisation au namespace racine** `DoctrineMigrations` dans `migrations/` (setup user, RBAC, seed de base). Les migrations modulaires (`src/Module/*/Infrastructure/Doctrine/Migrations/`) sont reservees aux evolutions post-schema (ajout de colonnes, index) — cf. @.claude/rules/architecture.md pour la raison. +12. **Toujours documenter chaque colonne BDD via `COMMENT ON COLUMN`** dans la migration qui la cree ou la modifie. Description en francais, courte (≤ 200 caracteres), explique la semantique metier + contraintes implicites (unicite partielle, FK importante, lien RG). Garde-fou : `tests/Architecture/ColumnsHaveSqlCommentTest` echoue si une colonne `public` n'a pas de description (`col_description IS NULL`). Details et exemples : @.claude/rules/backend.md § Migrations Doctrine. ## Conventions @.claude/rules/architecture.md diff --git a/config/version.yaml b/config/version.yaml index 3fde966..8e6a858 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.49' + app.version: '0.1.53' diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index a71d4db..77849a7 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -230,6 +230,39 @@ "updated": "Site mis à jour 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." + } } } } diff --git a/frontend/modules/catalog/components/CategoryDeleteModal.vue b/frontend/modules/catalog/components/CategoryDeleteModal.vue new file mode 100644 index 0000000..5842904 --- /dev/null +++ b/frontend/modules/catalog/components/CategoryDeleteModal.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue new file mode 100644 index 0000000..d780808 --- /dev/null +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -0,0 +1,178 @@ + + + diff --git a/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts new file mode 100644 index 0000000..a018863 --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useCategoriesAdmin.spec.ts @@ -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(items: T[]): HydraCollection { + 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([])) + 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([])) + const { fetchAll } = useCategoriesAdmin() + + await fetchAll() + + const queryArg = mockGet.mock.calls[0]?.[1] as Record + expect(queryArg).not.toHaveProperty('includeDeleted') + }) + + it('ajoute includeDeleted=true quand demande explicitement', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + 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) => void = () => {} + mockGet.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const { fetchAll, loading } = useCategoriesAdmin() + + const pending = fetchAll() + expect(loading.value).toBe(true) + + resolveRequest(makeHydra([])) + 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) + const { fetchAll, categories } = useCategoriesAdmin() + + await fetchAll() + + expect(categories.value).toEqual([]) + }) + }) + + describe('fetchTypes', () => { + it('appelle GET /category_types avec itemsPerPage=999', async () => { + mockGet.mockResolvedValueOnce(makeHydra([])) + 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) => void = () => {} + mockGet.mockImplementationOnce( + () => new Promise((resolve) => { resolveRequest = resolve }), + ) + const { fetchTypes, loadingTypes } = useCategoriesAdmin() + + const pending = fetchTypes() + expect(loadingTypes.value).toBe(true) + + resolveRequest(makeHydra([])) + 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]) + }) + }) +}) diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts new file mode 100644 index 0000000..c3500cd --- /dev/null +++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts @@ -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) => + 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) + }) + }) +}) diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts new file mode 100644 index 0000000..181326c --- /dev/null +++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts @@ -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([]) +const types = ref([]) +const loading = ref(false) +const loadingTypes = ref(false) +const error = ref(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 { + loading.value = true + error.value = null + try { + const query: Record = { itemsPerPage: HYDRA_NO_PAGINATION } + if (includeDeleted) { + query.includeDeleted = 'true' + } + const data = await api.get>( + '/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 { + loadingTypes.value = true + try { + const data = await api.get>( + '/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, + } +} diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts new file mode 100644 index 0000000..36e928e --- /dev/null +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -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(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(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` + * en TS strict). + */ + function buildCreatePayload(): Record { + 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 { + if (!validate()) return null + submitting.value = true + errors.value._global = '' + const payload = buildCreatePayload() + try { + const created = await api.post('/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 { + if (!validate()) return null + submitting.value = true + errors.value._global = '' + const payload: Record = {} + 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(`/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 { + 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, + } +} diff --git a/frontend/modules/catalog/nuxt.config.ts b/frontend/modules/catalog/nuxt.config.ts new file mode 100644 index 0000000..268da7f --- /dev/null +++ b/frontend/modules/catalog/nuxt.config.ts @@ -0,0 +1 @@ +export default defineNuxtConfig({}) diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue new file mode 100644 index 0000000..2acd257 --- /dev/null +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -0,0 +1,139 @@ + + + diff --git a/frontend/modules/catalog/types/category.ts b/frontend/modules/catalog/types/category.ts new file mode 100644 index 0000000..acb154d --- /dev/null +++ b/frontend/modules/catalog/types/category.ts @@ -0,0 +1,71 @@ +/** + * Types front du module Catalog (M0 — Gestion des categories). + * + * Contrats API consommes : + * - GET /api/categories → HydraCollection + * - 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 + * + * 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 +} diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue index c25c9ad..21ff0a0 100644 --- a/frontend/modules/core/pages/logout.vue +++ b/frontend/modules/core/pages/logout.vue @@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar() const { resetModules } = useModules() const { resetCurrentSite } = useCurrentSite() const { resetAuditLog } = useAuditLog() +const { resetCategoriesAdmin } = useCategoriesAdmin() onMounted(async () => { try { @@ -27,6 +28,7 @@ onMounted(async () => { resetModules() resetCurrentSite() resetAuditLog() + resetCategoriesAdmin() await navigateTo('/login') } }) diff --git a/frontend/shared/composables/useApi.ts b/frontend/shared/composables/useApi.ts index 344a6f5..b51aaa0 100644 --- a/frontend/shared/composables/useApi.ts +++ b/frontend/shared/composables/useApi.ts @@ -1,5 +1,6 @@ import type { FetchOptions , FetchError } from 'ofetch' import { $fetch } from 'ofetch' +import { extractApiErrorMessage } from '~/shared/utils/api' export type AnyObject = Record @@ -41,24 +42,8 @@ export function useApi(): ApiClient { function extractErrorMessage(error: unknown, responseData?: unknown): string { const data = responseData ?? (error as FetchError)?.data - - if (typeof data === 'string') { - return data - } - - if (data && typeof data === 'object') { - const record = data as Record - 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) || - '' - ) - } - + const msg = extractApiErrorMessage(data) + if (msg) return msg return (error as FetchError)?.message ?? 'Erreur inconnue.' } diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts index 8e57001..b8f24f6 100644 --- a/frontend/shared/utils/api.ts +++ b/frontend/shared/utils/api.ts @@ -31,3 +31,62 @@ export interface HydraCollection { export function extractHydraMembers(collection: HydraCollection): T[] { 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 + 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 + 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 + 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) + ?? '' + ) +} diff --git a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts index 9bdae9e..9e7a130 100644 --- a/frontend/tests/e2e/helpers/pages/SidebarComponent.ts +++ b/frontend/tests/e2e/helpers/pages/SidebarComponent.ts @@ -1,6 +1,6 @@ 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". diff --git a/makefile b/makefile index bf774a7..71ab933 100644 --- a/makefile +++ b/makefile @@ -207,10 +207,16 @@ migration-migrate: # recree par dbal:run-sql pour que les tests RG-1.07 (unicite # case-insensitive) voient bien la contrainte SQL. Sans ce restore, les # POST doublons remontent 201 au lieu de 409. +# 5. app:apply-column-comments : meme cause, schema:update drop les COMMENT +# ON COLUMN/TABLE des tables managees par l'ORM (le mapping PHP ne porte +# pas d'attribut options['comment']). On rejoue le catalogue partage +# `ColumnCommentsCatalog` pour conserver la documentation SQL exigee par +# le test architecture ColumnsHaveSqlCommentTest (ERP-67). test-db-setup: $(SYMFONY_CONSOLE) doctrine:database:create --env=test --if-not-exists $(SYMFONY_CONSOLE) doctrine:migrations:migrate --env=test --no-interaction $(SYMFONY_CONSOLE) doctrine:schema:update --env=test --force + $(SYMFONY_CONSOLE) --env=test --no-interaction app:apply-column-comments $(SYMFONY_CONSOLE) --env=test --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" diff --git a/migrations/Version20260528120000.php b/migrations/Version20260528120000.php new file mode 100644 index 0000000..6e38e14 --- /dev/null +++ b/migrations/Version20260528120000.php @@ -0,0 +1,66 @@ +addSql($sql); + } + } + + public function down(Schema $schema): void + { + foreach (ColumnCommentsCatalog::comments() as $table => $entries) { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + foreach ($entries as $column => $_) { + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $quotedTable)); + + continue; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS NULL', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + )); + } + } + } +} diff --git a/src/Module/Core/Infrastructure/Console/ApplyColumnCommentsCommand.php b/src/Module/Core/Infrastructure/Console/ApplyColumnCommentsCommand.php new file mode 100644 index 0000000..bd8dada --- /dev/null +++ b/src/Module/Core/Infrastructure/Console/ApplyColumnCommentsCommand.php @@ -0,0 +1,41 @@ +connection->executeStatement($sql); + } + + $io->success(sprintf('%d COMMENT ON statements appliques.', count($statements))); + + return Command::SUCCESS; + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php new file mode 100644 index 0000000..5312165 --- /dev/null +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -0,0 +1,188 @@ +> + */ + public static function comments(): array + { + return [ + 'audit_log' => [ + '_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.", + 'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).", + 'entity_type' => "Type d'entite auditee au format module.Entity (ex: core.User, commercial.Client) — evite les collisions inter-modules.", + 'entity_id' => "Identifiant de l'entite auditee (supporte INT et UUID — stocke en varchar pour rester generique).", + 'action' => "Type d'operation auditee : 'create', 'update' ou 'delete'.", + 'changes' => 'Snapshot complet pour create/delete, diff {champ: {old, new}} pour update. Cles sensibles filtrees (password, token, secret).', + 'performed_by' => "Username de l'auteur de l'action (denormalise, survit a la suppression du user) — vaut 'system' en CLI.", + 'performed_at' => "Horodatage UTC de l'action auditee.", + 'ip_address' => "Adresse IP de l'auteur (IPv4/IPv6) — null hors contexte HTTP.", + 'request_id' => "UUID v4 de la requete HTTP — regroupe les changements d'un meme flush, facilite la correlation logs.", + ], + + 'category' => [ + '_table' => 'Categories M0 — referentiel type par category_type, soft-delete via deleted_at, unicite (LOWER(name), category_type_id) parmi les actifs.', + 'id' => 'Identifiant interne auto-incremente.', + 'name' => 'Libelle de la categorie (≤ 120 caracteres) — unique par type parmi les actifs (RG-1.06).', + 'category_type_id' => 'Reference au type de la categorie — FK -> category_type.id, ON DELETE RESTRICT (un type ne peut etre supprime tant qu il a des categories).', + 'deleted_at' => 'Horodatage UTC du soft-delete (archivage logique) — null si la categorie est active.', + ] + self::timestampableBlamableComments(), + + 'category_type' => [ + '_table' => 'Referentiel statique des types de categories — code technique stable + libelle FR.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable du type (snake_case, ≤ 40 caracteres) — unique, utilise dans le code et les configurations.', + 'label' => 'Libelle affichable du type (FR, ≤ 120 caracteres).', + ], + + 'permission' => [ + '_table' => 'Referentiel des permissions RBAC — codes au format module.resource[.subresource].action, synchronise par app:sync-permissions.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code RBAC au format module.resource[.subresource].action — unique, synchronise par app:sync-permissions.', + 'label' => 'Libelle affichable de la permission (FR).', + 'module' => 'Identifiant du module proprietaire de la permission (snake_case, ex: core, commercial).', + 'orphan' => "Drapeau permission orpheline — vrai quand son module declarant a ete supprime, masquee de l'interface RBAC.", + ], + + 'role' => [ + '_table' => 'Referentiel des roles RBAC — agregent un ensemble de permissions, attribues aux utilisateurs.', + 'id' => 'Identifiant interne auto-incremente.', + 'code' => 'Code technique stable du role (snake_case) — utilise dans le code (ex: admin, user). Unique.', + 'label' => 'Libelle affichable du role (FR).', + 'description' => 'Description longue du role (optionnelle).', + 'is_system' => "Drapeau role systeme — bloque la suppression et la modification du code via l'interface.", + ], + + 'role_permission' => [ + '_table' => 'Table de jointure roles <-> permissions (ManyToMany).', + 'role_id' => 'FK -> role.id, ON DELETE CASCADE — role qui porte la permission.', + 'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission attribuee au role.', + ], + + 'site' => [ + '_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.', + 'id' => 'Identifiant interne auto-incremente.', + 'name' => 'Nom du site (≤ 100 caracteres).', + 'city' => 'Ville du site (≤ 100 caracteres).', + 'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.', + 'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.", + 'street' => "Numero et voie de l'adresse (≤ 200 caracteres).", + 'complement' => "Complement d'adresse (etage, batiment...) — optionnel.", + 'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.', + 'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.', + ], + + 'user' => [ + '_table' => 'Comptes utilisateurs Starseed — authentification JWT, RBAC via roles et permissions directes.', + 'id' => 'Identifiant interne auto-incremente.', + 'username' => 'Identifiant de connexion (≤ 100 caracteres) — unique.', + 'password' => 'Hash du mot de passe (algorithme courant Symfony) — exclu de l audit via #[AuditIgnore].', + 'created_at' => 'Horodatage UTC de creation du compte — rempli manuellement dans le constructeur (pas via TimestampableBlamableSubscriber).', + 'is_admin' => 'Drapeau super-administrateur — bypass complet RBAC. Faux par defaut.', + 'current_site_id' => "Site actuellement selectionne par l'utilisateur (contexte de session) — FK -> site.id, ON DELETE SET NULL.", + ], + + 'user_permission' => [ + '_table' => 'Table de jointure utilisateurs <-> permissions directes (hors role).', + 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur destinataire de la permission directe.', + 'permission_id' => 'FK -> permission.id, ON DELETE CASCADE — permission accordee individuellement.', + ], + + 'user_role' => [ + '_table' => 'Table de jointure utilisateurs <-> roles (ManyToMany).', + 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur portant le role.', + 'role_id' => 'FK -> role.id, ON DELETE CASCADE — role attribue a l utilisateur.', + ], + + 'user_site' => [ + '_table' => 'Table de jointure utilisateurs <-> sites accessibles — gere le scoping multi-site (un user ne voit que les donnees de ses sites).', + 'user_id' => 'FK -> user.id, ON DELETE CASCADE — utilisateur ayant acces au site.', + 'site_id' => 'FK -> site.id, ON DELETE CASCADE — site accessible par l utilisateur.', + ], + ]; + } + + /** + * Descriptions standardisees pour les 4 colonnes du pattern + * Timestampable/Blamable (`TimestampableBlamableTrait`). + * + * @return array + */ + public static function timestampableBlamableComments(): array + { + return [ + 'created_at' => 'Horodatage UTC de creation de la ligne — rempli par TimestampableBlamableSubscriber au prePersist.', + 'updated_at' => 'Horodatage UTC de derniere modification — rempli par TimestampableBlamableSubscriber au preUpdate.', + 'created_by' => "ID de l'utilisateur ayant cree la ligne — null hors HTTP (CLI, migration, fixture). FK -> \"user\".id, ON DELETE SET NULL.", + 'updated_by' => "ID de l'utilisateur ayant modifie la ligne en dernier — null hors HTTP. FK -> \"user\".id, ON DELETE SET NULL.", + ]; + } + + /** + * Construit la liste des requetes SQL `COMMENT ON TABLE/COLUMN` (en + * dollar-quoting Postgres `$_$`) a partir du catalogue. + * + * @return list + */ + public static function toSqlStatements(): array + { + $statements = []; + foreach (self::comments() as $table => $entries) { + $quotedTable = self::quoteIdent($table); + foreach ($entries as $column => $description) { + if ('_table' === $column) { + $statements[] = sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description); + + continue; + } + + $statements[] = sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + self::quoteIdent($column), + $description, + ); + } + } + + return $statements; + } + + /** + * Quote un identifiant SQL avec des guillemets doubles. Necessaire pour + * la table `user` (mot reserve PG) ; applique a tous par coherence. + */ + private static function quoteIdent(string $name): string + { + return '"'.str_replace('"', '""', $name).'"'; + } +} diff --git a/tests/Architecture/ColumnsHaveSqlCommentTest.php b/tests/Architecture/ColumnsHaveSqlCommentTest.php new file mode 100644 index 0000000..35902ee --- /dev/null +++ b/tests/Architecture/ColumnsHaveSqlCommentTest.php @@ -0,0 +1,114 @@ + + */ + private const EXCLUDED_TEST_FIXTURES = [ + // tests/Fixtures/SiteAware/FakeSiteAwareEntity.php — fixture du module + // Sites pour couvrir le SiteScopedQueryExtension. Cree via schema:update + // sur la DB de test uniquement. + 'fake_site_aware_entity', + ]; + + /** + * Whitelist metier — DOIT rester vide ou justifiee. + * + * Chaque entree doit comporter (1) un commentaire expliquant pourquoi la + * table n'est pas encore documentee et (2) la reference d'un ticket + * Lesstime ouvert pour le retrofit. + * + * @var list + */ + private const EXCLUDED_TABLES = []; + + public function testAllPublicColumnsHaveASqlComment(): void + { + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.default_connection'); + + $excluded = [...self::EXCLUDED_BUILTINS, ...self::EXCLUDED_TEST_FIXTURES, ...self::EXCLUDED_TABLES]; + + $rows = $conn->fetchAllAssociative( + <<<'SQL' + SELECT c.table_name, c.column_name + FROM information_schema.columns c + WHERE c.table_schema = 'public' + AND c.table_name NOT IN (:excluded) + AND col_description( + (c.table_schema || '.' || c.table_name)::regclass, + c.ordinal_position + ) IS NULL + ORDER BY c.table_name, c.ordinal_position + SQL, + ['excluded' => $excluded], + ['excluded' => ArrayParameterType::STRING], + ); + + if ([] !== $rows) { + $missing = array_map( + static fn (array $row): string => sprintf('%s.%s', $row['table_name'], $row['column_name']), + $rows, + ); + + self::fail(sprintf( + "%d colonne(s) sans COMMENT ON COLUMN — ajouter une description SQL dans la migration qui les cree (cf. .claude/rules/backend.md § Migrations Doctrine) :\n - %s", + count($missing), + implode("\n - ", $missing), + )); + } + + // Garde : si la requete ne renvoie rien et qu'aucune table publique + // n'existe (sauf doctrine_migration_versions), le test deviendrait un + // faux positif vert. On verifie qu'il y a bien des tables a auditer. + $tableCount = (int) $conn->fetchOne( + <<<'SQL' + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name NOT IN (:excluded) + SQL, + ['excluded' => $excluded], + ['excluded' => ArrayParameterType::STRING], + ); + + self::assertGreaterThan(0, $tableCount, 'Aucune table publique a auditer : schema vide ou whitelist trop large.'); + } +}