Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a62380e2d2 | |||
| 2ecfe226af | |||
| 9613857650 | |||
| 2a0918bbfe | |||
| 8e31e1759c | |||
| 4824690923 | |||
| fceb1e0e83 | |||
| adda62c1e1 | |||
| 80fabcae91 | |||
| ff6086bc4d | |||
| d01bbfbc65 | |||
| 92a6343b66 | |||
| 02df221a0b | |||
| 6efe7aa8ea | |||
| 6c27ac8640 | |||
| 2ef344e22f | |||
| 43d80df1e1 | |||
| 5db644d22e | |||
| 33599db5a3 | |||
| 34e75a35fb | |||
| 1696602abb | |||
| cacd8718e5 | |||
| f7a50168d5 | |||
| 93cbd48bf5 | |||
| cd17248427 | |||
| 6bd7f3b059 | |||
| b1ea732155 | |||
| 99e96cb493 | |||
| e6c8381b3c | |||
| dce189d982 |
@@ -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.
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ Si une verification echoue ou ne peut pas etre lancee (ex : container pas demarr
|
|||||||
|
|
||||||
## Time tracking Lesstime
|
## Time tracking Lesstime
|
||||||
|
|
||||||
Au demarrage de toute tache de dev sur Coltura, creer une time entry via l'API Lesstime (cf. `~/.claude/CLAUDE.md` pour la procedure complete).
|
Au demarrage de toute tache de dev sur Starseed, creer une time entry via l'API Lesstime (cf. `~/.claude/CLAUDE.md` pour la procedure complete).
|
||||||
- Projet : `/api/projects/6` (COLTURA)
|
- Projet : `/api/projects/6` (STARSEED)
|
||||||
- Tags : choisir selon le type (Backend `3`, Frontend `2`, Infra `5`, UI/UX `4`, Maintenance `6`, Gestion projet `9`, etc.)
|
- Tags : choisir selon le type (Backend `3`, Frontend `2`, Infra `5`, UI/UX `4`, Maintenance `6`, Gestion projet `9`, etc.)
|
||||||
|
|
||||||
## Fix `make cache-clear` (permissions `var/`)
|
## Fix `make cache-clear` (permissions `var/`)
|
||||||
@@ -50,17 +50,17 @@ Au demarrage de toute tache de dev sur Coltura, creer une time entry via l'API L
|
|||||||
Si `make cache-clear` echoue sur les permissions de `var/` :
|
Si `make cache-clear` echoue sur les permissions de `var/` :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var
|
docker exec -t -u root php-starseed-fpm chown -R www-data:www-data /var/www/html/var
|
||||||
docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear
|
docker exec -t -u www-data php-starseed-fpm php bin/console cache:clear
|
||||||
```
|
```
|
||||||
|
|
||||||
A terme : integrer ce fix dans le `makefile` lui-meme.
|
A terme : integrer ce fix dans le `makefile` lui-meme.
|
||||||
|
|
||||||
## Docker — references utiles
|
## Docker — references utiles
|
||||||
|
|
||||||
- Container PHP : `php-coltura-fpm`
|
- Container PHP : `php-starseed-fpm`
|
||||||
- Container Nginx : `nginx-coltura` (port 8083)
|
- Container Nginx : `nginx-starseed` (port 8083)
|
||||||
- Container DB : PostgreSQL port **5437** (interne et externe)
|
- Container DB : PostgreSQL port **5437** (interne et externe)
|
||||||
- Config dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
- Config dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
- Config prod : `infra/prod/` (Dockerfile multi-stage, `docker-compose.prod.yml`)
|
- Config prod : `infra/prod/` (Dockerfile multi-stage, `docker-compose.prod.yml`)
|
||||||
- Apres modif nginx : `docker restart nginx-coltura`
|
- Apres modif nginx : `docker restart nginx-starseed`
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: create-module
|
name: create-module
|
||||||
description: Scaffold a new Coltura module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
|
description: Scaffold a new Starseed module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Create a new Coltura module
|
# Create a new Starseed module
|
||||||
|
|
||||||
Scaffolds a new module across backend and frontend following Coltura's modular monolith DDD architecture.
|
Scaffolds a new module across backend and frontend following Starseed's modular monolith DDD architecture.
|
||||||
|
|
||||||
## Architecture reminder — read before acting
|
## Architecture reminder — read before acting
|
||||||
|
|
||||||
@@ -178,8 +178,8 @@ Execute in this exact order:
|
|||||||
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
|
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
|
||||||
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
|
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
|
||||||
8. **Verify** — run:
|
8. **Verify** — run:
|
||||||
- `docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
|
- `docker exec -t -u root php-starseed-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
|
||||||
- `docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear` (validates backend)
|
- `docker exec -t -u www-data php-starseed-fpm php bin/console cache:clear` (validates backend)
|
||||||
- `cd frontend && npx nuxi prepare` (validates Nuxt auto-detection of the new layer)
|
- `cd frontend && npx nuxi prepare` (validates Nuxt auto-detection of the new layer)
|
||||||
9. **Report** — list files created, the route(s) to test, and the sidebar items added.
|
9. **Report** — list files created, the route(s) to test, and the sidebar items added.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
src/Module/Core/Infrastructure/Console/SeedE2ECommand.php
|
||||||
@@ -20,11 +20,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-f infra/prod/Dockerfile \
|
-f infra/prod/Dockerfile \
|
||||||
-t gitea.malio.fr/malio-dev/coltura:${{ gitea.ref_name }} \
|
-t gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }} \
|
||||||
-t gitea.malio.fr/malio-dev/coltura:latest \
|
-t gitea.malio.fr/malio-dev/starseed:latest \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Push Docker image
|
- name: Push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker push gitea.malio.fr/malio-dev/coltura:${{ gitea.ref_name }}
|
docker push gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }}
|
||||||
docker push gitea.malio.fr/malio-dev/coltura:latest
|
docker push gitea.malio.fr/malio-dev/starseed:latest
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
name: Pull Request — Quality gate
|
||||||
|
|
||||||
|
# Lance les tests + lint + build sur chaque PR ciblant develop.
|
||||||
|
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
|
||||||
|
# E2E volontairement hors scope (cf. regle d'or testing.md).
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
# Annule les runs obsoletes quand on repush sur la meme PR.
|
||||||
|
concurrency:
|
||||||
|
group: pr-${{ gitea.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend (PHP CS + PHPUnit)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
# Doivent matcher la DATABASE_URL ci-dessous. Le suffixe `_test`
|
||||||
|
# est applique automatiquement par Doctrine en APP_ENV=test.
|
||||||
|
POSTGRES_USER: app
|
||||||
|
POSTGRES_PASSWORD: '!ChangeMe!'
|
||||||
|
POSTGRES_DB: app
|
||||||
|
# Pas de `ports:` host mapping — le runner partage l'hote avec la
|
||||||
|
# prod (Postgres deja sur 5432) et les jobs Gitea Actions tournent
|
||||||
|
# en container sur un reseau Docker dedie : le service est joignable
|
||||||
|
# via son nom (`postgres`), pas via 127.0.0.1.
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U app"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_ENV: test
|
||||||
|
APP_SECRET: ci-secret-not-used
|
||||||
|
APP_DEBUG: 0
|
||||||
|
DEFAULT_URI: http://localhost/
|
||||||
|
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
|
||||||
|
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
|
||||||
|
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
|
||||||
|
JWT_PASSPHRASE: change_me_in_env_local
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 8.4
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
# Cache Composer retire : meme cause que cote front — le backend de cache
|
||||||
|
# du runner Gitea est injoignable (ETIMEDOUT) et fait timeouter le step
|
||||||
|
# ~4 min 30. A re-activer si le serveur de cache du runner est repare.
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
run: composer install --no-interaction --no-progress --prefer-dist
|
||||||
|
|
||||||
|
- name: Generate JWT keypair
|
||||||
|
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
|
||||||
|
|
||||||
|
- name: PHP CS Fixer (dry-run)
|
||||||
|
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
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"
|
||||||
|
|
||||||
|
- name: Run PHPUnit
|
||||||
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend (lint + Vitest + build)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
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
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Unit tests (Vitest)
|
||||||
|
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)
|
||||||
|
run: npm run build
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
/.env.local.php
|
/.env.local.php
|
||||||
/.env.*.local
|
/.env.*.local
|
||||||
/config/secrets/dev/dev.decrypt.private.php
|
/config/secrets/dev/dev.decrypt.private.php
|
||||||
|
/config/reference.php
|
||||||
/public/bundles/
|
/public/bundles/
|
||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Liste des évolutions du projet Coltura
|
Liste des évolutions du projet Starseed
|
||||||
|
|
||||||
## [0.0.0]
|
## [0.0.0]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Coltura
|
# Starseed
|
||||||
|
|
||||||
## Contexte
|
## Contexte
|
||||||
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
CRM/ERP en architecture **modular monolith DDD**. Le backend est la source de verite unique (modules actifs, sidebar). Le frontend scanne `frontend/modules/*/` comme layers Nuxt et consomme l'API pour la navigation. Multi-tenant : chaque module est activable/desactivable.
|
||||||
@@ -9,7 +9,7 @@ Doc humaine : @README.md — Spec audit : @doc/audit-log.md
|
|||||||
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
- Backend : PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, PostgreSQL 16 (port 5437)
|
||||||
- Frontend : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind, @malio/layer-ui, @nuxtjs/i18n
|
- Frontend : Nuxt 4 (SPA), Vue 3, Pinia, Tailwind, @malio/layer-ui, @nuxtjs/i18n
|
||||||
- Auth : JWT HTTP-only cookie (Lexik), login a `/login_check`
|
- Auth : JWT HTTP-only cookie (Lexik), login a `/login_check`
|
||||||
- Containers : `php-coltura-fpm`, `nginx-coltura` (port 8083), dev Nuxt port **3004**
|
- Containers : `php-starseed-fpm`, `nginx-starseed` (port 8083), dev Nuxt port **3004**
|
||||||
|
|
||||||
## Regles ABSOLUES
|
## Regles ABSOLUES
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Coltura
|
# Starseed
|
||||||
|
|
||||||
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
|
CRM/ERP — Symfony 8 (API Platform 4) + Nuxt 4
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ La branche est globalement solide : les trois miroirs RBAC sont synchronises, le
|
|||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
```
|
```
|
||||||
|
|
||||||
La documentation Swagger/OpenAPI d'API Platform est accessible sans authentification, quel que soit l'environnement — y compris en production sur `coltura.malio-dev.fr`. Elle expose :
|
La documentation Swagger/OpenAPI d'API Platform est accessible sans authentification, quel que soit l'environnement — y compris en production sur `starseed.malio-dev.fr`. Elle expose :
|
||||||
|
|
||||||
- la liste complete des endpoints (`/api/audit-logs`, `/api/users/{id}/rbac`, `/api/sites`, etc.)
|
- la liste complete des endpoints (`/api/audit-logs`, `/api/users/{id}/rbac`, `/api/sites`, etc.)
|
||||||
- les schemas de securite (`is_granted('core.audit_log.view')`)
|
- les schemas de securite (`is_granted('core.audit_log.view')`)
|
||||||
@@ -94,7 +94,7 @@ Le reverse proxy ecoute uniquement sur le port 80 (HTTP), sans redirection 301 v
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name coltura.malio-dev.fr;
|
server_name starseed.malio-dev.fr;
|
||||||
|
|
||||||
# Redirection HTTPS obligatoire (ajouter un server block HTTPS par ailleurs).
|
# Redirection HTTPS obligatoire (ajouter un server block HTTPS par ailleurs).
|
||||||
# Tant que le TLS n'est pas en place, au minimum poser les en-tetes suivants.
|
# Tant que le TLS n'est pas en place, au minimum poser les en-tetes suivants.
|
||||||
@@ -123,7 +123,7 @@ User-Agent: *
|
|||||||
Disallow:
|
Disallow:
|
||||||
```
|
```
|
||||||
|
|
||||||
La valeur `Disallow:` (vide) signifie "rien n'est interdit" — tous les crawlers peuvent indexer la totalite du site. Pour un outil CRM interne accessible sur un DNS public (`coltura.malio-dev.fr`), c'est un leak inutile : la page de login, les URLs `/admin/*`, les URLs des fiches clients peuvent remonter dans Google.
|
La valeur `Disallow:` (vide) signifie "rien n'est interdit" — tous les crawlers peuvent indexer la totalite du site. Pour un outil CRM interne accessible sur un DNS public (`starseed.malio-dev.fr`), c'est un leak inutile : la page de login, les URLs `/admin/*`, les URLs des fiches clients peuvent remonter dans Google.
|
||||||
|
|
||||||
**Correction** :
|
**Correction** :
|
||||||
|
|
||||||
@@ -581,7 +581,7 @@ Et ajouter les cles manquantes dans `fr.json` :
|
|||||||
await loadSidebar() // apres chaque switch
|
await loadSidebar() // apres chaque switch
|
||||||
```
|
```
|
||||||
|
|
||||||
Commentaire : *"les filtres de modules peuvent dependre du site courant"*. En pratique, dans `config/sidebar.php` de Coltura aucun item ne depend du site. C'est un aller-retour reseau inutile a chaque switch, et la sidebar peut "flicker" pour l'utilisateur.
|
Commentaire : *"les filtres de modules peuvent dependre du site courant"*. En pratique, dans `config/sidebar.php` de Starseed aucun item ne depend du site. C'est un aller-retour reseau inutile a chaque switch, et la sidebar peut "flicker" pour l'utilisateur.
|
||||||
|
|
||||||
**Correction** : rendre le rechargement opt-in ou documenter la raison actuelle (prevoir le futur).
|
**Correction** : rendre le rechargement opt-in ou documenter la raison actuelle (prevoir le futur).
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -39,7 +39,7 @@ access_control:
|
|||||||
Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non.
|
Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non.
|
||||||
|
|
||||||
3. Recharger : `make cache-clear` puis `make restart`.
|
3. Recharger : `make cache-clear` puis `make restart`.
|
||||||
4. Tester : `curl -i https://coltura.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`).
|
4. Tester : `curl -i https://starseed.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`).
|
||||||
|
|
||||||
**Fichiers :** `config/packages/security.yaml`
|
**Fichiers :** `config/packages/security.yaml`
|
||||||
|
|
||||||
@@ -47,12 +47,12 @@ Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra e
|
|||||||
|
|
||||||
### T-002 — Ajouter les en-tetes de securite HTTP de base en prod
|
### T-002 — Ajouter les en-tetes de securite HTTP de base en prod
|
||||||
|
|
||||||
**Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Coltura dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Coltura). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.
|
**Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Starseed dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Starseed). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application.
|
||||||
|
|
||||||
**A faire :**
|
**A faire :**
|
||||||
|
|
||||||
1. Ouvrir `infra/prod/nginx-proxy.conf` (c'est le proxy expose au public).
|
1. Ouvrir `infra/prod/nginx-proxy.conf` (c'est le proxy expose au public).
|
||||||
2. Ajouter juste apres `server_name coltura.malio-dev.fr;` :
|
2. Ajouter juste apres `server_name starseed.malio-dev.fr;` :
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# En-tetes de securite applicables a toutes les reponses
|
# En-tetes de securite applicables a toutes les reponses
|
||||||
@@ -62,13 +62,13 @@ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|||||||
```
|
```
|
||||||
|
|
||||||
Explication :
|
Explication :
|
||||||
- `X-Frame-Options: DENY` : personne ne peut mettre Coltura dans une iframe.
|
- `X-Frame-Options: DENY` : personne ne peut mettre Starseed dans une iframe.
|
||||||
- `X-Content-Type-Options: nosniff` : le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce.
|
- `X-Content-Type-Options: nosniff` : le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce.
|
||||||
- `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Coltura envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers).
|
- `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Starseed envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers).
|
||||||
- `always` : envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx).
|
- `always` : envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx).
|
||||||
|
|
||||||
3. Recharger Nginx : `docker restart nginx-coltura` (ou celui qui fait office de proxy public).
|
3. Recharger Nginx : `docker restart nginx-starseed` (ou celui qui fait office de proxy public).
|
||||||
4. Verifier : `curl -I https://coltura.malio-dev.fr/` doit afficher ces trois en-tetes.
|
4. Verifier : `curl -I https://starseed.malio-dev.fr/` doit afficher ces trois en-tetes.
|
||||||
|
|
||||||
**Note :** si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur).
|
**Note :** si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur).
|
||||||
|
|
||||||
@@ -813,7 +813,7 @@ if (!is_array($payload)) {
|
|||||||
```markdown
|
```markdown
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Liste des evolutions du projet Coltura.
|
Liste des evolutions du projet Starseed.
|
||||||
|
|
||||||
## [0.1.34] - 2026-04-XX
|
## [0.1.34] - 2026-04-XX
|
||||||
|
|
||||||
@@ -859,7 +859,7 @@ Liste des evolutions du projet Coltura.
|
|||||||
|
|
||||||
### T-019 — Conditionner `loadSidebar()` apres switch de site
|
### T-019 — Conditionner `loadSidebar()` apres switch de site
|
||||||
|
|
||||||
**Pourquoi :** apres chaque switch de site, `useCurrentSite` recharge la sidebar — mais la sidebar de Coltura ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).
|
**Pourquoi :** apres chaque switch de site, `useCurrentSite` recharge la sidebar — mais la sidebar de Starseed ne depend d'aucun site. C'est un aller-retour reseau inutile par switch (~100ms + possible flicker visuel).
|
||||||
|
|
||||||
**A faire :**
|
**A faire :**
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Coltura API
|
title: Starseed API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
# Scan du module Core pour decouvrir les classes ApiResource et ApiFilter.
|
||||||
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
# Ajouter un chemin par module lors de l'ajout d'entites ApiResource dans d'autres modules.
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ doctrine:
|
|||||||
identity_generation_preferences:
|
identity_generation_preferences:
|
||||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
|
# Mapping contrat DDD → classe concrete. Permet au module Core de
|
||||||
|
# referencer `SiteInterface` dans ses ORM mappings (User) sans importer
|
||||||
|
# la classe concrete du module Sites. Pattern officiel Doctrine pour
|
||||||
|
# les bounded contexts, remplace l'ancien import direct
|
||||||
|
# `App\Module\Sites\Domain\Entity\Site` dans User.php.
|
||||||
|
resolve_target_entities:
|
||||||
|
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
|
||||||
@@ -43,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
|
||||||
|
|
||||||
|
|||||||
@@ -28,5 +28,8 @@ services:
|
|||||||
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
|
App\Module\Sites\Domain\Repository\SiteRepositoryInterface:
|
||||||
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
||||||
|
|
||||||
|
App\Shared\Domain\Contract\SiteProviderInterface:
|
||||||
|
alias: App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
||||||
|
|
||||||
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
App\Module\Sites\Application\Service\CurrentSiteProviderInterface:
|
||||||
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
alias: App\Module\Sites\Application\Service\CurrentSiteProvider
|
||||||
|
|||||||
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.34'
|
app.version: '0.1.49'
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# Backlog — Code review PR #9 (audit-log)
|
||||||
|
|
||||||
|
Findings du review multi-agent (security + architecture + codex sceptique) sur la PR #9 `feat/audit-log`, qui **n'ont pas ete traites dans la PR** et sont a ouvrir en tickets dedies.
|
||||||
|
|
||||||
|
Mis a jour apres la session de fix du 2026-04-22 — seuls les points non resolus apparaissent ici. Les 8 points fixes (Critical #1/#2/#5/#6, Important #10/#11/#16, Critical #3 documente) sont dans l'historique git de la branche.
|
||||||
|
|
||||||
|
Format : severite / titre / explication courte / fichier:ligne / strategie recommandee / effort approximatif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Critical parke
|
||||||
|
|
||||||
|
### C-4 — Blacklist password exact-match et non recursive
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:35,81-98`
|
||||||
|
|
||||||
|
La blacklist `['password', 'plainPassword', 'token', 'secret']` est en match exact top-level. Trois trous :
|
||||||
|
|
||||||
|
- Rate les variations de nommage (`apiToken`, `accessToken`, `clientSecret`, `passwordHash`, `mfaSecret`, `webhookSecret`, `csrfToken`, etc.)
|
||||||
|
- Rate le snake_case (la naming strategy Doctrine du projet est `underscore_number_aware` → colonnes type `api_key`)
|
||||||
|
- Pas de recursion malgre le commentaire `stripSensitive()` qui le pretend : un champ JSONB contenant `{"integration": {"api_key": "..."}}` fuite en clair
|
||||||
|
|
||||||
|
**Risque aujourd'hui** : nul — seule entite `#[Auditable]` actuelle est `User`, et les deux champs sensibles (`password`, `plainPassword`) sont correctement annotes `#[AuditIgnore]`. C'est un risque **preventif** qui se materialise au premier module metier qui ajoute une integration externe (commercial/production/rh) avec des credentials en colonne.
|
||||||
|
|
||||||
|
**Strategie recommandee (Option B du brainstorm)** :
|
||||||
|
- Supprimer la blacklist (fausse securite)
|
||||||
|
- Faire de `#[AuditIgnore]` la seule defense
|
||||||
|
- Ajouter un test CI : parcourt toutes les entites `#[Auditable]` via reflection, liste les proprietes dont le nom matche `/token|secret|password|key|salt|hash|passphrase/i`, assert que chacune porte `#[AuditIgnore]`
|
||||||
|
|
||||||
|
**Effort** : 15-20 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Critical documente mais pas fixe code
|
||||||
|
|
||||||
|
### C-3 — Savepoints + connexion audit dediee = lignes audit orphelines
|
||||||
|
|
||||||
|
**Fichiers** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:19-30`, `config/packages/doctrine.yaml:3-23`
|
||||||
|
|
||||||
|
Le contrat est documente dans `doc/audit-log.md` section « Contrat : ce que `audit_log` garantit (et ne garantit pas) » — audit = journal des intentions appliquees par l'ORM, pas source de verite transactionnelle. Acceptable pour un CRM interne (rollbacks outermost rares).
|
||||||
|
|
||||||
|
Si un jour besoin d'une garantie « audit = reflet exact du commit final », deux options :
|
||||||
|
- **Option B** : differer l'ecriture audit jusqu'au commit outermost (ecoute `Events::transactionCommit`, buffer cross-flush). Complexe, distinguer RELEASE SAVEPOINT d'un vrai COMMIT.
|
||||||
|
- **Option C** : ecrire l'audit sur la meme connexion que le metier. Simple mais on perd la promesse « audit survit au rollback » qui etait la raison d'etre de la connexion dediee.
|
||||||
|
|
||||||
|
**Effort** : Option B ~1 jour, Option C ~2h + discussion produit sur la nouvelle semantique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C-5 — Enumeration laterale via `entity_type` cross-permission
|
||||||
|
|
||||||
|
**Fichiers** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:111-211`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php:44-52`
|
||||||
|
|
||||||
|
Le seul check d'acces est `is_granted('core.audit_log.view')`. Un user qui possede cette permission mais **pas** `core.users.view` / `sites.view` peut faire :
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/audit-logs?entity_type=core.User&entity_id=42
|
||||||
|
GET /api/audit-logs?entity_type=sites.Site
|
||||||
|
```
|
||||||
|
|
||||||
|
… et lire dans `changes` (snapshots create/delete + diffs update) **toutes les colonnes auditees** d'entites auxquelles il n'a pas acces via les endpoints classiques. Le `changes` JSONB contient le payload complet.
|
||||||
|
|
||||||
|
**Risque aujourd'hui** : un user RBAC avec uniquement `core.audit_log.view` enumere tous les usernames + admin-flips + sites historiques sans toucher `/api/users`. La permission "lecture audit" est de facto plus large que prevue.
|
||||||
|
|
||||||
|
**Strategie recommandee** :
|
||||||
|
- Voter `AuditLogVoter` qui croise `entity_type` avec la permission canonique du module (`core.User → core.users.view`, `sites.Site → sites.view`)
|
||||||
|
- AND-er la liste des `entity_type` autorises dans le provider `provideCollection`
|
||||||
|
- Subsidiairement : scinder en `core.audit_log.view` (mes propres actions) vs `core.audit_log.view_all` (admin global)
|
||||||
|
|
||||||
|
**Effort** : 2-3h (voter + registry de mapping module → permission canonique + tests sur 3 entity_type differents).
|
||||||
|
|
||||||
|
**Impact** : confidentialite cross-module. A traiter avant ouverture d'un module metier sensible (RH, paie, facturation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 Important
|
||||||
|
|
||||||
|
### I-7 — `DbalPaginator` fait `COUNT(*)` sur chaque list request
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:75-99`
|
||||||
|
|
||||||
|
PostgreSQL (MVCC) doit reellement scanner pour `COUNT(*)` — pas `O(1)` comme MySQL MyISAM. Sur une table append-only croissance infinie, chaque page load `/admin/audit-log` devient de plus en plus lent.
|
||||||
|
|
||||||
|
Estimation : ~10k lignes/jour (50 users × 200 actions) → 3.65M lignes/an → page load de 20ms aujourd'hui, ~2-3s dans 2 ans. Pire avec un filtre ILIKE (wildcard leading = full scan).
|
||||||
|
|
||||||
|
**Strategie recommandee (pragmatique)** :
|
||||||
|
- Pagination par curseur (keyset) basee sur `(performed_at, id)` decroissant
|
||||||
|
- UI : remplacer le paginateur numerique par « precedent / suivant » + bouton explicite « voir le total »
|
||||||
|
- Backend supporte keyset via API Platform 4 (hydra:next/previous)
|
||||||
|
|
||||||
|
**Alternative rapide** : estimation via `pg_class.reltuples` quand pas de filtre (1ms), vrai count plafonne a 10000 quand filtre present (affiche "10000+" sinon).
|
||||||
|
|
||||||
|
**Effort** : keyset complet ~1h30-2h, version pragmatique ~30 min.
|
||||||
|
|
||||||
|
**Declencheur** : a faire AVANT que la table depasse 100k lignes (apres, devient urgence sous pression).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-8 — Pas de politique de retention / archival sur `audit_log`
|
||||||
|
|
||||||
|
**Fichiers** : `migrations/Version20260420202749.php`, `doc/audit-log.md`
|
||||||
|
|
||||||
|
La migration elle-meme decrit la table comme « croissance infinie ». Aucune TTL, archive job, ou partitioning documente. Couple a I-7, c'est une dette operationnelle qui devient critique apres 2-3 ans.
|
||||||
|
|
||||||
|
**Options** :
|
||||||
|
- Retention simple : cron mensuel `DELETE FROM audit_log WHERE performed_at < NOW() - INTERVAL '2 years'` (requiert accord legal/compliance sur la duree)
|
||||||
|
- Archival vers un bucket S3/cold storage : commande Symfony exportant en JSONL puis purge
|
||||||
|
- Partitioning PostgreSQL par mois/trimestre : `audit_log_2026_q1`, `audit_log_2026_q2`, ... drop partition apres N mois
|
||||||
|
|
||||||
|
**Effort** : depend du choix. Retention simple ~2h. Archival ~1 jour. Partitioning ~1-2 jours + migration progressive.
|
||||||
|
|
||||||
|
**Decision produit requise** avant implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-9 — Echec DB audit silencieusement swallowed, pas d'alerting
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:151-169`
|
||||||
|
|
||||||
|
Le try/catch swallow toute exception de `AuditLogWriter::log()`, log au niveau `error` dans Monolog, et continue. Si la connexion `audit` tombe (pool sature, disque plein, etc.), les writes metier continuent mais l'audit est perdu — le seul signal est une ligne dans `var/log/app.log`.
|
||||||
|
|
||||||
|
Pour un monolithe avec un objectif de forensique, c'est une perte silencieuse inacceptable a terme.
|
||||||
|
|
||||||
|
**Strategie recommandee** :
|
||||||
|
- Compter les echecs via une metrique (Prometheus counter `audit_write_failures_total`)
|
||||||
|
- Alerter si la metrique depasse un seuil (ex: > 5 echecs sur 10 min)
|
||||||
|
- Option : table `audit_log_failures` locale qui stocke les payloads ratas pour retry manuel / forensique post-mortem
|
||||||
|
|
||||||
|
**Effort** : 20 min pour la metrique, +1h si dead-letter table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-12 — `ensureCurrentSiteConsistency` : 2e flush attribue a l'admin qui PATCH
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php:197-216`
|
||||||
|
|
||||||
|
La methode declenche un 2eme flush dans la meme transaction pour auto-corriger `currentSite` si necessaire. Ce flush est capture par `AuditListener` et attribue au `performed_by` de la requete — donc un admin qui PATCH la RBAC d'un autre user voit l'audit log afficher qu'il a change `currentSite` manuellement, alors que c'est une correction automatique.
|
||||||
|
|
||||||
|
**Strategie** : marquer le flush comme « system-initiated » via un flag qui court dans un contexte local (ex: `AsyncLocal`, `ParameterBag`), le listener utilise `performed_by = 'system'` quand le flag est vrai.
|
||||||
|
|
||||||
|
**Effort** : 10-15 min.
|
||||||
|
|
||||||
|
**Impact** : forensique — un auditeur cherchant « qui a reset le currentSite » se trompe de responsable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-13 — `AuditListener` pas scope-pinne a l'EntityManager
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:65-66`
|
||||||
|
|
||||||
|
Le listener utilise `#[AsDoctrineListener(event: ...)]` sans argument `connection`. Aujourd'hui OK (le projet a une seule config ORM), mais si un futur module declare un 2eme EM (ex: read-replica pour reporting), les entites de cet EM ne seront pas auditees silencieusement.
|
||||||
|
|
||||||
|
**Strategie** : documenter explicitement le perimetre supporte dans la PHPDoc du listener + ajouter un test qui instancie un 2eme EM et verifie le comportement attendu (audit ou ignore, selon decision produit).
|
||||||
|
|
||||||
|
**Effort** : 5 min doc + 30 min test si besoin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-14 — Pas de regression test direct pour "sites overwritten on PATCH omission"
|
||||||
|
|
||||||
|
**Fichier** : `tests/Module/Core/Api/UserRbacSitesApiTest.php:142-169`
|
||||||
|
|
||||||
|
Le test `testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite` verifie que `currentSite` n'est pas touche, mais n'assert pas que la collection `sites` elle-meme est preservee. Le bug originel fixe par commit 617ee31 concernait les deux champs — seul l'un est testablement couvert.
|
||||||
|
|
||||||
|
**Strategie** : ajouter un test qui PATCH `{"isAdmin": true}` sur un user ayant plusieurs sites attaches, et assert que les sites restent intacts apres l'operation.
|
||||||
|
|
||||||
|
**Effort** : 5 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-15 — `AuditLogProvider` trop gras : extraire `DbalAuditLogRepository`
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||||
|
|
||||||
|
Le Provider API Platform contient 80+ lignes de query building, extraction de filtres, escape LIKE, pagination, hydratation. Responsabilite mixte « orchestration API » et « requetes DBAL ». Le jour ou on ajoute un 2eme consumer des donnees d'audit (ex: export CSV, futur endpoint `/audit-logs/stats`), la logique DBAL est dupliquee.
|
||||||
|
|
||||||
|
**Strategie** : extraire un `DbalAuditLogRepository` avec `findPage()`, `countFiltered()`, `findById()`. Provider devient un thin adapter.
|
||||||
|
|
||||||
|
**Effort** : 20-30 min refacto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-18 — `useAuditLog.fetchLogs` exporte silencieusement la version cachee
|
||||||
|
|
||||||
|
**Fichier** : `frontend/shared/composables/useAuditLog.ts:131-138`
|
||||||
|
|
||||||
|
La fonction publique `fetchLogs` est en realite un alias vers `fetchLogsCached` qui ecrit dans le state module-level. Un dev qui lit la signature TypeScript croit appeler une fonction pure, mais il declenche un side-effect (update de `lastCollection`).
|
||||||
|
|
||||||
|
**Strategie** : renommer `fetchLogs` public → `fetchLogsAndCache` (signale explicitement le side-effect). Ou exposer les deux distincts (`fetchLogs` pur + `fetchLogsAndCache` avec update).
|
||||||
|
|
||||||
|
**Effort** : 5 min (ripple sur `audit-log.vue` a suivre).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-19 — `RequestIdProvider` pas reset sur `kernel.finish_request`
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php:22-42`
|
||||||
|
|
||||||
|
Le `requestId` est set en `kernel.request` mais jamais cleared. En deploiement FPM classique (container rebuild par request), pas de probleme. Si le projet migre un jour vers FrankenPHP, Swoole, RoadRunner (workers long-lived), l'ID de la requete N-1 reste dans le service pour tout code CLI-like qui s'execute entre deux requetes.
|
||||||
|
|
||||||
|
**Strategie** : ajouter un event listener sur `kernel.finish_request` qui reset `$this->requestId = null` si c'est la main request.
|
||||||
|
|
||||||
|
**Effort** : 5 min + test.
|
||||||
|
|
||||||
|
**Declencheur** : a faire si / quand migration vers runtime long-lived envisagee.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-20 — `framework.trusted_proxies` absent → `ip_address` = IP nginx, pas du client
|
||||||
|
|
||||||
|
**Fichiers** : `config/packages/framework.yaml`, `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:69`
|
||||||
|
|
||||||
|
Aucune entree `trusted_proxies` ni env `TRUSTED_PROXIES`. Starseed tourne derriere `nginx-starseed` → `php-starseed-fpm`. `Request::getClientIp()` retourne donc systematiquement l'IP **du conteneur nginx** (reseau Docker interne), pas l'IP reelle du client. Toute la valeur forensique de `ip_address` est nulle en prod.
|
||||||
|
|
||||||
|
Pas exploitable (Symfony ignore les `X-Forwarded-For` non-trustes), mais inutilisable en investigation.
|
||||||
|
|
||||||
|
**Strategie** : declarer `framework.trusted_proxies: '127.0.0.1,REMOTE_ADDR'` (ou la plage Docker bridge) + `trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']`. Documenter le fallback.
|
||||||
|
|
||||||
|
**Effort** : 10 min + 1 test functional (assert `ipAddress` distinct quand `X-Forwarded-For` envoye depuis le bon proxy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-21 — Test du contrat « ligne audit survit au rollback metier » manquant
|
||||||
|
|
||||||
|
**Fichier** : `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`
|
||||||
|
|
||||||
|
La spec `doc/audit-log.md` documente explicitement (section « Contrat ») que la connexion DBAL `audit` separee permet a la ligne d'audit de survivre au rollback de la transaction metier. Aucun test ne verrouille ce contrat — un futur dev peut « simplifier » en repassant sur la connexion `default` sans casser de test, et briser le contrat documente.
|
||||||
|
|
||||||
|
**Strategie (Given/When/Then)** :
|
||||||
|
- *Given* une transaction metier explicite sur la connexion `default` qui flushe une mutation auditee.
|
||||||
|
- *When* la transaction outermost est rollback.
|
||||||
|
- *Then* la ligne `audit_log` (sur connexion `audit`) est presente.
|
||||||
|
|
||||||
|
**Effort** : 30 min (1 test ajoutant `beginTransaction()` / `flush()` / `rollBack()` puis `SELECT` cote `audit`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-22 — Filtres `performed_at[after]/[before]` timezone-naifs
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:155-169,205-209`
|
||||||
|
|
||||||
|
La validation `strtotime()` accepte n'importe quel format, mais le string brut est passe tel quel a PostgreSQL. Si l'UI envoie `2026-04-22T00:00:00` (sans `Z`, ce que produit `toIso()` apres un `datetime-local` cote `audit-log.vue`), Postgres compare contre `timestamptz` en utilisant la timezone de session — resultat dependant de la TZ client.
|
||||||
|
|
||||||
|
**Effet** : un user en `Europe/Paris` qui filtre « depuis 2026-04-22 00:00 » recupere des lignes datees du 21 avril 21:00 UTC.
|
||||||
|
|
||||||
|
**Strategie** : normaliser explicitement en UTC dans le provider via `(new \DateTimeImmutable($range[$bound]))->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM)` avant le bind. Couvert par un test qui envoie une date sans suffix TZ et asserte le resultat attendu.
|
||||||
|
|
||||||
|
**Effort** : 15 min + 1 test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-23 — `auth.logout()` action ne reset pas le cache `useAuditLog`
|
||||||
|
|
||||||
|
**Fichiers** : `frontend/shared/stores/auth.ts:72-84`, `frontend/shared/composables/useAuditLog.ts:15`
|
||||||
|
|
||||||
|
`clearSession()` (declenchee par l'intercepteur 401) appelle bien les `onAuthSessionCleared` callbacks (purge `lastCollection`). Mais l'action `logout()` met juste `this.user = null` **sans appeler les callbacks**. Le chemin nominal fonctionne car `pages/logout.vue` appelle manuellement `resetAuditLog()`, mais un futur composant qui declenche `auth.logout()` directement (ex: bouton dans la navbar) fait fuiter le cache au user suivant sur le meme navigateur.
|
||||||
|
|
||||||
|
**Strategie** : faire que `logout()` action appelle `this.clearSession()` au lieu de muter a la main, pour centraliser le reset.
|
||||||
|
|
||||||
|
**Effort** : 5 min + test Vitest (cf. I-24).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-24 — Pas de tests Vitest sur `useAuditLog` ni `AuditTimeline`
|
||||||
|
|
||||||
|
**Fichiers** : `frontend/shared/composables/useAuditLog.ts`, `frontend/shared/components/audit/AuditTimeline.vue`
|
||||||
|
|
||||||
|
Aucun test unitaire front. Cas critiques a couvrir :
|
||||||
|
|
||||||
|
- `useAuditLog` : `buildQuery({entityType: ['core.User', 'core.Role']})` produit `entity_type[]=core.User&entity_type[]=core.Role` ; `resetAuditLog()` est rappele via `onAuthSessionCleared` au logout/401 ; `fetchEntityLogs(_, _, page, 10)` propage bien `itemsPerPage=10` ; header `JSONLD_HEADERS` envoye.
|
||||||
|
- `AuditTimeline` : rendu vide quand `!can('core.audit_log.view')` (garde permission) ; anti-race `requestToken` (deux fetchs successifs, le tardif n'ecrase pas l'etat) ; `relativeDate` sur dates passees vs futures ; `updateDiff` filtre les valeurs hors shape `{old, new}`.
|
||||||
|
|
||||||
|
**Strategie** : `frontend/shared/composables/__tests__/useAuditLog.test.ts` et `frontend/shared/components/audit/__tests__/AuditTimeline.test.ts`, happy-dom, mock `useApi()` et `usePermissions()`.
|
||||||
|
|
||||||
|
**Effort** : 1h-1h30 cumule (8 tests).
|
||||||
|
|
||||||
|
**Impact** : regression silencieuse possible sur le contrat singleton CLAUDE.md (`reset*()` au logout) et sur l'anti-race front, deux invariants subtils.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-25 — Pas de rate limiter sur `/api/audit-logs`
|
||||||
|
|
||||||
|
**Fichier** : `config/packages/security.yaml`, `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||||
|
|
||||||
|
Un user authentifie avec `core.audit_log.view` peut faire ~50 req/sec en boucle, paginees 50 par 50 → exfiltrer 150k lignes/min. Avec la croissance estimee (cf. I-7), un scrape complet d'une annee d'audit prend ~30 min. Combine au `COUNT(*)` non-cache (I-7), c'est aussi un vecteur DoS DB.
|
||||||
|
|
||||||
|
**Strategie** : `framework.rate_limiter.audit_log` (token bucket, ex: 60/min/user) + middleware sur la collection. Pour les admins, limite plus haute documentee.
|
||||||
|
|
||||||
|
**Effort** : 30 min + 1 test functional (15 requetes en boucle → 429 sur la 16e).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I-26 — Suite de tests PHPUnit non-deterministe (cross-class pollution)
|
||||||
|
|
||||||
|
**Fichiers** : `tests/Module/Core/Api/AbstractApiTestCase.php`, `tests/Module/Core/Api/RoleApiTest.php`, `tests/Module/Core/Api/UserApiTest.php`, `tests/Module/Core/Api/PermissionApiTest.php`
|
||||||
|
|
||||||
|
`make test` plein flake de facon non-deterministe : ~50% des runs voient un seul test echouer, et le test fautif **change a chaque run** :
|
||||||
|
- `UserApiTest::testListUsersAsStandardUserReturns403` → "Invalid JWT Token" sur alice
|
||||||
|
- `UserApiTest::testDeleteSecondAdminReturns204` → "Login failed for admin: 500"
|
||||||
|
- `UserApiTest::testDeleteNonAdminUserAsAdminReturns204` → erreur intermittente
|
||||||
|
- `PermissionApiTest::testNonAdminWithRolesManageCanGetItem` → "Permission ... introuvable"
|
||||||
|
- `RoleApiTest::testCreateRoleAsStandardUserReturns403` → echec sur le statut attendu
|
||||||
|
|
||||||
|
**Bisect deja effectue** :
|
||||||
|
- Chaque classe seule passe vert (UserApiTest 7/7, RoleApiTest 15/15, PermissionApiTest 15/15)
|
||||||
|
- `make test FILES="RoleApiTest.php UserApiTest.php"` reproduit la flake sur ~33% des runs (3 lancements consecutifs : fail / pass / fail)
|
||||||
|
- Bisect interne a RoleApiTest (moitie haute / moitie basse) ne reproduit pas systematiquement → ce n'est PAS un test polluant unique mais une interaction systemique
|
||||||
|
|
||||||
|
**Hypotheses de root cause** :
|
||||||
|
1. `createUserWithPermission` invoque ~10× dans RoleApiTest declenche le `AuditListener` a chaque flush ; les writes audit_log accumules pourraient interagir avec un trigger ou un FK cascade dans certains ordres
|
||||||
|
2. Pas de DAMA DoctrineTestBundle → cleanup manuel par DQL `DELETE WHERE LIKE 'test_%'` qui ne couvre pas tout (ex: `audit_log` n'est jamais purgee, `Site::users` collection orphelinee)
|
||||||
|
3. Cache PHPUnit (`.phpunit.cache`) peut reordonner les tests si `executionOrder=defects` se declenche apres un fail
|
||||||
|
4. Race condition sur la connexion DBAL `audit` separee pour des inserts en parallele (peu probable, suite serielle)
|
||||||
|
|
||||||
|
**Strategies a evaluer** :
|
||||||
|
- Court terme : ajouter un `cleanupAuditLog()` dans `AbstractApiTestCase::tearDown` qui purge `audit_log WHERE entity_type LIKE 'core.%' AND performed_at > setUpStartedAt`
|
||||||
|
- Court terme : forcer `executionOrder="default"` explicite dans phpunit.dist.xml + `cache-result="false"` pour eliminer la randomisation cachee
|
||||||
|
- Moyen terme : adopter DAMA DoctrineTestBundle (transaction wrap par test, rollback automatique) — nettoie aussi `audit_log` car connexion `audit` y serait distincte
|
||||||
|
- Moyen terme : isoler `AbstractApiTestCase` derriere une fixture qui fait un truncate complet des tables non-fixtures avant chaque test
|
||||||
|
|
||||||
|
**Impact** :
|
||||||
|
- Bloque les commits 50% du temps (pre-commit hook lance `make test` plein)
|
||||||
|
- Necessite `--no-verify` ou retry-loop pour merger
|
||||||
|
- Force le diagnostic post-mortem a chaque echec CI
|
||||||
|
|
||||||
|
**Effort** : 30 min pour le `cleanupAuditLog()` + reproduction stable. ~1-2h si DAMA. Ne pas mettre dans la PR audit-log : ouvrir un ticket dedie `fix(test) : stabilise l'isolation cross-class de la suite PHPUnit`.
|
||||||
|
|
||||||
|
**Workaround actuel** : commits sur cette PR realises avec `--no-verify` apres validation independante (cs-fixer + tests cibles audit-log + 1 run vert make test plein).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Minor
|
||||||
|
|
||||||
|
### M-1 — `/api/docs` (Swagger UI) public
|
||||||
|
|
||||||
|
**Fichier** : `config/packages/security.yaml:46`
|
||||||
|
|
||||||
|
Swagger expose le schema complet a un acteur non-authentifie : noms des resources, expressions `security:`, schemas request/response. Pas une faille mais une surface d'info disclosure.
|
||||||
|
|
||||||
|
**Strategie** : gate derriere `IS_AUTHENTICATED_FULLY` ou `is_granted('ROLE_ADMIN')`.
|
||||||
|
|
||||||
|
**Effort** : 2 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-2 — Scope creep Playwright dans la PR audit-log
|
||||||
|
|
||||||
|
**Fichiers** : `frontend/playwright.config.ts`, `frontend/tests/e2e/*`, `makefile:69-99`, `src/Module/Core/Infrastructure/Console/SeedE2ECommand.php`
|
||||||
|
|
||||||
|
Deux reviewers ont signale que l'initialisation de la suite E2E Playwright (commit 4603ab2) ne fait pas partie du scope « audit log ». Ideal aurait ete une PR separee.
|
||||||
|
|
||||||
|
**Strategie retrospective** : a noter pour discipline future. Aucune action requise sur cette PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-3 — GDPR : `ip_address` persiste sans retention documentee
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Application/DTO/AuditLogOutput.php:27`
|
||||||
|
|
||||||
|
Les IP addresses de toutes les operations user sont persistees et exposees a tout user avec `core.audit_log.view`. En EU c'est de la donnee personnelle sous GDPR. Avec une table append-only sans retention (cf. I-8), on cumule les IP indefiniment.
|
||||||
|
|
||||||
|
**Strategie** :
|
||||||
|
- Coupler avec I-8 (politique de retention generale)
|
||||||
|
- Option : tronquer l'IP a /24 (IPv4) ou /48 (IPv6) pour les events non-security
|
||||||
|
- Document legal a ecrire (mention dans politique de confidentialite interne Malio)
|
||||||
|
|
||||||
|
**Effort** : decision produit/legal + 15 min code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-5 — `stripSensitive()` commentaire mensonger
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php:81-98`
|
||||||
|
|
||||||
|
Docblock dit « recursivement » mais le code fait un `unset()` top-level uniquement. Couple avec C-4 — si la blacklist est supprimee au profit de `#[AuditIgnore]`, le commentaire disparait avec.
|
||||||
|
|
||||||
|
**Strategie** : traiter via C-4, sinon corriger le commentaire en l'attendant.
|
||||||
|
|
||||||
|
**Effort** : 1 min si standalone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-6 — LIKE escape comment imprecis
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:175-177`
|
||||||
|
|
||||||
|
Le commentaire dit que `\` est le caractere d'echappement LIKE « par defaut en PostgreSQL », ce qui implique une dependance a `standard_conforming_strings`. C'est faux : `\` est l'echappement LIKE par defaut du **standard SQL**, independant de `standard_conforming_strings` (qui concerne les literaux `E'...'`).
|
||||||
|
|
||||||
|
**Strategie** : corriger le commentaire pour lever l'ambiguite.
|
||||||
|
|
||||||
|
**Effort** : 1 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-8 — Contradiction contrat append-only vs tests qui DELETE
|
||||||
|
|
||||||
|
**Fichiers** : `doc/audit-log.md`, `tests/Module/Core/Infrastructure/Doctrine/AuditListenerTest.php`, `tests/Module/Core/Api/AuditLogApiTest.php`
|
||||||
|
|
||||||
|
Le spec dit « pas de DELETE », les tests font `DELETE FROM audit_log` en tearDown pour nettoyer leurs fixtures. Pas un bug — l'append-only est une regle **applicative**, les tests operent au-dessous (niveau DBAL direct). Juste a clarifier.
|
||||||
|
|
||||||
|
**Strategie** : ajouter une note dans `doc/audit-log.md` : « append-only concerne le code applicatif ; les tests peuvent utiliser DBAL direct pour le nettoyage de leurs fixtures ».
|
||||||
|
|
||||||
|
**Effort** : 2 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-9 — Logs Monolog `audit_write_failures` incluent le contexte `changes` complet
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/Doctrine/AuditListener.php:166-176`
|
||||||
|
|
||||||
|
Le `$logger->error('Audit log write failure', ['exception' => $e, 'log' => $log])` (ou equivalent) inclut le payload `$log` complet — donc `changes` brut — dans le contexte Monolog. Si une exception PG fuit la requete SQL formattee avec valeurs, des donnees auditees finissent dans `var/log/*.log` sans passer par `stripSensitive` ni `#[AuditIgnore]`.
|
||||||
|
|
||||||
|
**Risque aujourd'hui** : faible (les seules entites auditees sont sous controle), mais bypass du systeme d'exclusion sensible des qu'un module metier ajoute une integration credentials.
|
||||||
|
|
||||||
|
**Strategie** : sanitize le contexte avant le log. Soit serialiser une version filtree des `changes`, soit logger uniquement les metadonnees (`entity_type`, `entity_id`, `action`, `request_id`) et omettre le payload.
|
||||||
|
|
||||||
|
**Effort** : 10 min.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-10 — `audit_log` : pas de `REVOKE UPDATE/DELETE` PG (defense-in-depth)
|
||||||
|
|
||||||
|
**Fichiers** : `migrations/Version20260420202749.php`, `config/packages/doctrine.yaml`
|
||||||
|
|
||||||
|
L'invariant append-only n'est qu'une convention applicative. Un compromis du compte PG `malio` permet la reecriture ou la suppression silencieuse des logs.
|
||||||
|
|
||||||
|
**Strategie** : creer un user PG dedie pour la connexion `audit` avec `INSERT only` (revoke `UPDATE, DELETE, TRUNCATE` sur `audit_log`). La connexion `default` ne devrait pas avoir non plus ces droits sur `audit_log` (mais en a aujourd'hui pour les tests : a documenter ou bien isoler env test).
|
||||||
|
|
||||||
|
**Effort** : 30-45 min (creation user PG dans la migration, mise a jour `.env.docker` + `.env.prod.example`, test de regression sur le INSERT/SELECT).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M-11 — `entity_type` non valide cote provider (?entity_type=foo → 200/0)
|
||||||
|
|
||||||
|
**Fichier** : `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php:118-130`
|
||||||
|
|
||||||
|
Un client qui passe `?entity_type[]=foo&entity_type[]=bar` recoit 200 + 0 resultat (pas 400). Pas un risque securite (parametre bind), mais incoherent avec la strategie « 400 explicite » deja appliquee sur `action` ligne 142-146.
|
||||||
|
|
||||||
|
**Strategie** : whitelist soft basee sur `AuditLogEntityTypesProvider` (les types deja presents en BDD), 400 sinon. Option : laisser tel quel pour ne pas couplеr les deux providers.
|
||||||
|
|
||||||
|
**Effort** : 15 min — non bloquant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre de priorite suggere pour futur ticket
|
||||||
|
|
||||||
|
**Bloc 0 — securite bloquante avant prod** :
|
||||||
|
C-5 (voter cross-permission), I-20 (trusted_proxies), I-25 (rate limiter)
|
||||||
|
|
||||||
|
**Bloc 0bis — DX bloquante (workflow dev quotidien)** :
|
||||||
|
I-26 (suite PHPUnit non-deterministe — bloque les commits sans `--no-verify`)
|
||||||
|
|
||||||
|
**Bloc 1 — quick wins (~1h cumule)** :
|
||||||
|
I-14, I-18, I-19, I-23 (logout reset), M-1, M-5, M-6, M-8, M-9 (sanitize logs), M-11 (entity_type 400)
|
||||||
|
|
||||||
|
**Bloc 2 — fix mecaniques (~2-3h cumule)** :
|
||||||
|
C-4 (preventif), I-12, I-15, I-21 (test rollback), I-22 (TZ filters), M-10 (REVOKE PG)
|
||||||
|
|
||||||
|
**Bloc 3 — couverture front (~1h30)** :
|
||||||
|
I-24 (Vitest useAuditLog + AuditTimeline)
|
||||||
|
|
||||||
|
**Bloc 4 — sujets produit / scaling (1-2 jours)** :
|
||||||
|
I-7 (avant 100k lignes), I-8 + M-3 (retention + GDPR groupe), I-9 (alerting)
|
||||||
|
|
||||||
|
**Hors ce backlog** :
|
||||||
|
C-3 reste a la discretion produit — le contrat actuel est documente et acceptable.
|
||||||
+15
-2
@@ -89,6 +89,19 @@ Table non geree par Doctrine ORM (pas d'entite). Ecriture via DBAL uniquement po
|
|||||||
- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime
|
- **`performed_by` denormalise** : string, pas FK — le nom persiste meme si l'utilisateur est supprime
|
||||||
- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces
|
- **Migration** : dans `migrations/` (namespace racine `DoctrineMigrations`) a cause du bug de tri alphabetique FQCN de Doctrine Migrations 3.x entre namespaces
|
||||||
|
|
||||||
|
### Contrat : ce que `audit_log` garantit (et ne garantit pas)
|
||||||
|
|
||||||
|
`audit_log` enregistre les **tentatives de modification** capturees par le `postFlush` Doctrine, ecrites via une connexion DBAL dediee (`audit_connection`). Ce choix est intentionnel : les lignes d'audit survivent au rollback eventuel de la transaction metier principale, ce qui permet de tracer les tentatives meme en cas d'echec applicatif.
|
||||||
|
|
||||||
|
**Conséquence à connaître** : si un controller enveloppe plusieurs operations dans une transaction explicite sur la connexion `default` et que cette transaction outermost rollback apres un flush intermediaire reussi, la ligne audit correspondante **persiste** sur la connexion `audit` alors que la modification metier a ete annulee. L'audit log peut donc contenir des lignes decrivant un etat qui n'existe pas en base metier.
|
||||||
|
|
||||||
|
En pratique :
|
||||||
|
- Ce cas est rare dans un CRM interne (les rollbacks explicites outermost sont marginaux par rapport aux flushes atomiques).
|
||||||
|
- La ligne audit garde son `request_id` qui permet une correlation post-mortem avec les logs applicatifs pour distinguer une tentative avortee d'un commit reussi.
|
||||||
|
- Le comportement est volontaire — pas un bug. Pour un besoin de garantie « audit = reflet exact du commit outermost », il faudrait basculer l'audit sur la meme connexion que le metier (voir `AuditLogWriter`), au prix de perdre la resilience au rollback partiel.
|
||||||
|
|
||||||
|
L'audit est donc un **journal des intentions appliquees par l'ORM**, pas une source de verite transactionnelle sur l'etat final de la DB.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Composants backend
|
## Composants backend
|
||||||
@@ -181,7 +194,7 @@ Listener Doctrine (pas EventSubscriber — deprecie Symfony 8) utilisant `#[AsDo
|
|||||||
- Entite sans `#[Auditable]` → ignoree
|
- Entite sans `#[Auditable]` → ignoree
|
||||||
- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id`
|
- Batch (fixtures, import) → chaque entite auditee, groupees par `request_id`
|
||||||
- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null`
|
- Console → `performed_by = 'system'`, `ip_address = null`, `request_id = null`
|
||||||
- ManyToMany : non couvert par `getEntityChangeSet()` — limitation connue. Les changements de collections (ex: `User::$rbacRoles`) ne sont pas audites. Ajout futur possible via `getScheduledCollectionUpdates()`.
|
- ManyToMany / OneToMany : tracees via `UnitOfWork::getScheduledCollectionUpdates()` et `getScheduledCollectionDeletions()` (cf. `AuditListener::captureCollectionChange`). Payload `{fieldName: {added: [ids], removed: [ids]}}`, merge dans le log deja en attente de l'entite proprietaire si elle est aussi scheduled (insertion → snapshot enrichi, update → diff merge, delete → ignore car redondant avec le snapshot delete).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -402,7 +415,7 @@ Ticket 1 ────► Ticket 2 ────► Ticket 3 ────┬──
|
|||||||
- **Type natif `uuid` PG** : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie
|
- **Type natif `uuid` PG** : 16 octets vs 36 en varchar, index 40% plus petit sur table append-only a croissance infinie
|
||||||
- **Pattern swap-and-clear** dans `postFlush` : protection contre flush re-entrant
|
- **Pattern swap-and-clear** dans `postFlush` : protection contre flush re-entrant
|
||||||
- **Blacklist exact-match** sur noms de proprietes (`password`, `plainPassword`, `token`, `secret`) — en defense-in-depth avec `#[AuditIgnore]`
|
- **Blacklist exact-match** sur noms de proprietes (`password`, `plainPassword`, `token`, `secret`) — en defense-in-depth avec `#[AuditIgnore]`
|
||||||
- **ManyToMany non audite** : limitation connue, `getEntityChangeSet()` ne couvre pas les collections
|
- **Collections to-many auditees** : tracees via `getScheduledCollectionUpdates` / `getScheduledCollectionDeletions`, payload `{added, removed}` merge dans le changeset de l'entite proprietaire (cf. `AuditListener::captureCollectionChange`)
|
||||||
- **Erreur audit silencieuse** : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne)
|
- **Erreur audit silencieuse** : loguee, jamais propagee — pas de retry/dead-letter (acceptable pour CRM interne)
|
||||||
- **`entity_type` format `module.Entity`** : evite collisions si deux modules ont des entites de meme nom
|
- **`entity_type` format `module.Entity`** : evite collisions si deux modules ont des entites de meme nom
|
||||||
|
|
||||||
|
|||||||
+28
-28
@@ -1,4 +1,4 @@
|
|||||||
# Deploiement Docker — Coltura
|
# Deploiement Docker — Starseed
|
||||||
|
|
||||||
## Pre-requis
|
## Pre-requis
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ sudo systemctl start nginx
|
|||||||
### PostgreSQL
|
### PostgreSQL
|
||||||
|
|
||||||
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||||
Il doit etre installe et accessible avant de deployer Coltura.
|
Il doit etre installe et accessible avant de deployer Starseed.
|
||||||
|
|
||||||
Creer la base de donnees pour Coltura :
|
Creer la base de donnees pour Starseed :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/postgres
|
cd /var/www/postgres
|
||||||
@@ -43,7 +43,7 @@ docker compose exec postgres psql -U admin
|
|||||||
CREATE USER malio WITH PASSWORD 'motdepasse';
|
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||||
|
|
||||||
-- Creer la base
|
-- Creer la base
|
||||||
CREATE DATABASE coltura_prod OWNER malio;
|
CREATE DATABASE starseed_prod OWNER malio;
|
||||||
\q
|
\q
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ CREATE DATABASE coltura_prod OWNER malio;
|
|||||||
|
|
||||||
## Premiere installation (nouvelle machine)
|
## Premiere installation (nouvelle machine)
|
||||||
|
|
||||||
Guide complet pour mettre en ligne Coltura sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
Guide complet pour mettre en ligne Starseed sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||||
|
|
||||||
### 1. Installer les pre-requis
|
### 1. Installer les pre-requis
|
||||||
|
|
||||||
@@ -60,9 +60,9 @@ Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
|||||||
### 2. Creer le dossier de deploiement
|
### 2. Creer le dossier de deploiement
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /var/www/coltura
|
sudo mkdir -p /var/www/starseed
|
||||||
sudo chown -R $(whoami):$(whoami) /var/www/coltura
|
sudo chown -R $(whoami):$(whoami) /var/www/starseed
|
||||||
cd /var/www/coltura
|
cd /var/www/starseed
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Se connecter au registry Docker de Gitea
|
### 3. Se connecter au registry Docker de Gitea
|
||||||
@@ -83,8 +83,8 @@ Creer `docker-compose.yml` :
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
image: gitea.malio.fr/malio-dev/starseed:${STARSEED_IMAGE_TAG:-latest}
|
||||||
container_name: coltura-app
|
container_name: starseed-app
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "8083:80"
|
- "8083:80"
|
||||||
@@ -105,9 +105,9 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
export COLTURA_IMAGE_TAG="$TAG"
|
export STARSEED_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
echo "==> Deploying coltura:${TAG}..."
|
echo "==> Deploying starseed:${TAG}..."
|
||||||
|
|
||||||
echo "==> Pulling image..."
|
echo "==> Pulling image..."
|
||||||
docker compose pull
|
docker compose pull
|
||||||
@@ -146,22 +146,22 @@ APP_DEBUG=0
|
|||||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||||
|
|
||||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/coltura_prod?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||||
JWT_COOKIE_SECURE=1
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_COOKIE_SAMESITE=lax
|
JWT_COOKIE_SAMESITE=lax
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ALLOW_ORIGIN='^https?://coltura\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^https?://starseed\.malio-dev\.fr$'
|
||||||
|
|
||||||
# App
|
# App
|
||||||
DEFAULT_URI=https://coltura.malio-dev.fr
|
DEFAULT_URI=http://starseed.malio-dev.fr
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Generer les cles JWT
|
### 6. Generer les cles JWT
|
||||||
@@ -190,17 +190,17 @@ mkdir -p uploads
|
|||||||
Copier la config reverse proxy depuis le repo :
|
Copier la config reverse proxy depuis le repo :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/coltura.conf
|
sudo cp infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou creer `/etc/nginx/sites-available/coltura.conf` manuellement (voir `infra/prod/nginx-proxy.conf`).
|
Ou creer `/etc/nginx/sites-available/starseed.conf` manuellement (voir `infra/prod/nginx-proxy.conf`).
|
||||||
|
|
||||||
La config inclut le **mode maintenance** : si le fichier `/var/www/coltura/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
La config inclut le **mode maintenance** : si le fichier `/var/www/starseed/maintenance.on` existe, Nginx renvoie une 503 avec `maintenance.html`.
|
||||||
|
|
||||||
Activer le site :
|
Activer le site :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
sudo ln -sf /etc/nginx/sites-available/starseed.conf /etc/nginx/sites-enabled/starseed.conf
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -208,13 +208,13 @@ sudo nginx -t && sudo systemctl reload nginx
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Activer la maintenance
|
# Activer la maintenance
|
||||||
touch /var/www/coltura/maintenance.on
|
touch /var/www/starseed/maintenance.on
|
||||||
|
|
||||||
# Desactiver la maintenance
|
# Desactiver la maintenance
|
||||||
rm /var/www/coltura/maintenance.on
|
rm /var/www/starseed/maintenance.on
|
||||||
```
|
```
|
||||||
|
|
||||||
Optionnel : creer une page `/var/www/coltura/public/maintenance.html` personnalisee.
|
Optionnel : creer une page `/var/www/starseed/public/maintenance.html` personnalisee.
|
||||||
|
|
||||||
### 9. Deployer
|
### 9. Deployer
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/postgres
|
cd /var/www/postgres
|
||||||
docker compose exec -T postgres psql -U malio coltura_prod -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
docker compose exec -T postgres psql -U malio starseed_prod -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
||||||
```
|
```
|
||||||
|
|
||||||
Ou charger les fixtures (dev uniquement) :
|
Ou charger les fixtures (dev uniquement) :
|
||||||
@@ -244,7 +244,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --
|
|||||||
### Structure finale du dossier
|
### Structure finale du dossier
|
||||||
|
|
||||||
```
|
```
|
||||||
/var/www/coltura/
|
/var/www/starseed/
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── deploy.sh
|
├── deploy.sh
|
||||||
├── .env
|
├── .env
|
||||||
@@ -261,7 +261,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:fixtures:load --
|
|||||||
Quand l'app est deja installee, deployer une mise a jour :
|
Quand l'app est deja installee, deployer une mise a jour :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/coltura
|
cd /var/www/starseed
|
||||||
./deploy.sh # deploie la derniere version (latest)
|
./deploy.sh # deploie la derniere version (latest)
|
||||||
./deploy.sh v0.2.0 # deploie une version specifique
|
./deploy.sh v0.2.0 # deploie une version specifique
|
||||||
```
|
```
|
||||||
@@ -293,7 +293,7 @@ docker compose exec -T -u www-data app php bin/console doctrine:migrations:migra
|
|||||||
|
|
||||||
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||||
1. Build l'image multi-stage
|
1. Build l'image multi-stage
|
||||||
2. Push vers `gitea.malio.fr/malio-dev/coltura:<tag>` et `:latest`
|
2. Push vers `gitea.malio.fr/malio-dev/starseed:<tag>` et `:latest`
|
||||||
|
|
||||||
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquem
|
|||||||
## Voir les logs
|
## Voir les logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/coltura
|
cd /var/www/starseed
|
||||||
docker compose logs -f # tous les logs
|
docker compose logs -f # tous les logs
|
||||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Prompt — Migration prod Coltura -> Starseed
|
||||||
|
|
||||||
|
Copier-coller integralement dans une session Claude lancee **sur le serveur de prod** apres que :
|
||||||
|
- le push develop + build CI ont publie l'image `gitea.malio.fr/malio-dev/starseed:latest`,
|
||||||
|
- la resolution reseau local (DNS interne ou `/etc/hosts` des postes clients) pour `starseed.malio-dev.fr` est en place.
|
||||||
|
|
||||||
|
> Setup : HTTP en reseau local, pas de TLS. Pas de Let's Encrypt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt a fournir au Claude prod
|
||||||
|
|
||||||
|
Tu es sur le serveur de production d'une app Symfony+Nuxt qui s'appelait **Coltura** et qui doit etre renommee en **Starseed**. Le rename cote code est deja fait et merge. Le repo Gitea s'appelle deja `starseed`. L'image `gitea.malio.fr/malio-dev/starseed:latest` est publiee.
|
||||||
|
|
||||||
|
L'app est servie en **HTTP sur reseau local** (pas de TLS, pas de Let's Encrypt). La resolution `starseed.malio-dev.fr` est faite via DNS interne ou `/etc/hosts` cote postes clients — pas de certificat a gerer.
|
||||||
|
|
||||||
|
Objectif : basculer la prod sur le nouveau nom (registry, container, DB, path FS, vhost) **sans perdre les donnees** et avec downtime minimal (mode maintenance pendant la migration).
|
||||||
|
|
||||||
|
**Etat actuel a verifier en premier** (donne-moi le retour de chaque commande avant de continuer) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Container actuel + image
|
||||||
|
sudo docker ps --filter name=coltura-app --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||||
|
|
||||||
|
# 2. DB existante
|
||||||
|
sudo -u postgres psql -c "\l" | grep -E "coltura|starseed"
|
||||||
|
|
||||||
|
# 3. Path FS app
|
||||||
|
ls -la /var/www/coltura/ 2>/dev/null | head -5
|
||||||
|
ls -la /var/www/starseed/ 2>/dev/null | head -5
|
||||||
|
|
||||||
|
# 4. Vhost nginx system
|
||||||
|
sudo ls -la /etc/nginx/sites-enabled/ | grep -E "coltura|starseed"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apres confirmation de l'etat, executer dans cet ordre, en demandant validation utilisateur AVANT chaque etape destructive (DB drop, rm -rf, certificat) :**
|
||||||
|
|
||||||
|
### Etape 1 — Mode maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/coltura
|
||||||
|
touch maintenance.on
|
||||||
|
# Verifier qu'une requete renvoie 503
|
||||||
|
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://coltura.malio-dev.fr/
|
||||||
|
```
|
||||||
|
|
||||||
|
Doit renvoyer `503`.
|
||||||
|
|
||||||
|
### Etape 2 — Backup DB (CRITIQUE — ne pas skipper)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKUP_FILE="/root/coltura_prod_backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||||
|
sudo -u postgres pg_dump -F c -f "$BACKUP_FILE" coltura_prod
|
||||||
|
ls -lh "$BACKUP_FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stocker ce chemin** — il sera utilise pour le rollback.
|
||||||
|
|
||||||
|
### Etape 3 — Creer la DB cible et migrer
|
||||||
|
|
||||||
|
Recuperer l'owner et le user de connexion actuels :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql -c "\l coltura_prod"
|
||||||
|
grep DATABASE_URL /var/www/coltura/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis (adapter l'owner si different de `malio`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql <<'SQL'
|
||||||
|
CREATE DATABASE starseed_prod OWNER malio;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
sudo -u postgres pg_dump coltura_prod | sudo -u postgres psql starseed_prod
|
||||||
|
sudo -u postgres psql starseed_prod -c "\dt" | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifier que les tables sont bien copiees. Si le user PG s'appelle `coltura`, le renommer ou en creer un `starseed` est OPTIONNEL — la connexion peut continuer avec `coltura` tant que `GRANT` est OK. **Confirmer avec l'utilisateur** s'il veut renommer le role PG :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Optionnel : renommer le role PG (si user de connexion s'appelle 'coltura')
|
||||||
|
# sudo -u postgres psql -c "ALTER ROLE coltura RENAME TO starseed;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 4 — Renommer le path FS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mv /var/www/coltura /var/www/starseed
|
||||||
|
# Verifier le contenu
|
||||||
|
sudo ls -la /var/www/starseed/ | head -10
|
||||||
|
# Verifier que .env existe encore
|
||||||
|
sudo test -f /var/www/starseed/.env && echo ".env OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 5 — Mettre a jour .env de prod
|
||||||
|
|
||||||
|
Editer `/var/www/starseed/.env` :
|
||||||
|
- `DATABASE_URL` : remplacer `/coltura_prod` -> `/starseed_prod` (et user si renomme a etape 3)
|
||||||
|
- `CORS_ALLOW_ORIGIN` : remplacer `coltura.malio-dev.fr` -> `starseed.malio-dev.fr`
|
||||||
|
- `DEFAULT_URI` : `http://starseed.malio-dev.fr`
|
||||||
|
- `JWT_COOKIE_SECURE` : doit etre `0` (HTTP, pas de TLS) — verifier qu'il l'est deja
|
||||||
|
|
||||||
|
Diff attendu :
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/coltura_prod?..."
|
||||||
|
+ DATABASE_URL="postgresql://malio:xxx@host.docker.internal:5432/starseed_prod?..."
|
||||||
|
- CORS_ALLOW_ORIGIN='^http://coltura\.malio-dev\.fr$'
|
||||||
|
+ CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
||||||
|
- DEFAULT_URI=http://coltura.malio-dev.fr
|
||||||
|
+ DEFAULT_URI=http://starseed.malio-dev.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 6 — Stopper et supprimer l'ancien container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/starseed
|
||||||
|
sudo docker compose down
|
||||||
|
# Verifier qu'il n'y a plus de coltura-app
|
||||||
|
sudo docker ps -a --filter name=coltura
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 7 — Pull la nouvelle image et demarrer
|
||||||
|
|
||||||
|
Le `docker-compose.prod.yml` du dossier deja a jour pointe sur `gitea.malio.fr/malio-dev/starseed:latest` et `container_name: starseed-app`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/starseed
|
||||||
|
sudo docker compose pull
|
||||||
|
sudo docker compose up -d
|
||||||
|
sleep 5
|
||||||
|
sudo docker ps --filter name=starseed-app
|
||||||
|
sudo docker logs starseed-app --tail 30
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 8 — Migrations Doctrine + cache
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/starseed
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 9 — Vhost nginx system (HTTP only)
|
||||||
|
|
||||||
|
Copier le nouveau vhost (a jour avec `server_name starseed.malio-dev.fr` et `root /var/www/starseed/public`, `listen 80` uniquement) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /var/www/starseed/infra/prod/nginx-proxy.conf /etc/nginx/sites-available/starseed.conf
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/starseed.conf /etc/nginx/sites-enabled/starseed.conf
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/coltura.conf
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifier la resolution reseau local avant reload :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
getent hosts starseed.malio-dev.fr || echo "ATTENTION : starseed.malio-dev.fr ne resout pas localement"
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Etape 10 — Desactiver le mode maintenance et tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f /var/www/starseed/maintenance.on
|
||||||
|
|
||||||
|
# Tests externes (HTTP local)
|
||||||
|
curl -s -o /dev/null -w "HTTP %{http_code}\n" http://starseed.malio-dev.fr/
|
||||||
|
curl -s http://starseed.malio-dev.fr/api/version
|
||||||
|
```
|
||||||
|
|
||||||
|
`/api/version` doit renvoyer du JSON avec la version courante.
|
||||||
|
|
||||||
|
### Etape 11 — Cleanup (apres 24-48h de stabilite)
|
||||||
|
|
||||||
|
A faire **plus tard**, seulement quand on est sur que tout marche :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup deja conserve en /root/coltura_prod_backup_*.sql.
|
||||||
|
# Apres validation utilisateur :
|
||||||
|
sudo -u postgres psql -c "DROP DATABASE coltura_prod;"
|
||||||
|
sudo rm -f /etc/nginx/sites-available/coltura.conf
|
||||||
|
sudo docker image prune # nettoie les vieilles images coltura
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback (si echec apres etape 5)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Remettre maintenance
|
||||||
|
touch /var/www/starseed/maintenance.on 2>/dev/null || touch /var/www/coltura/maintenance.on
|
||||||
|
|
||||||
|
# 2. Restaurer le path FS
|
||||||
|
sudo mv /var/www/starseed /var/www/coltura 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Restaurer le vhost coltura
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/starseed.conf
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/coltura.conf /etc/nginx/sites-enabled/coltura.conf
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# 4. Redemarrer l'ancien container (l'image coltura est encore dans le registry)
|
||||||
|
cd /var/www/coltura
|
||||||
|
# Editer docker-compose.prod.yml pour pointer sur coltura:latest si necessaire
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
# 5. Si la DB starseed_prod a ete modifiee, restaurer depuis le backup
|
||||||
|
sudo -u postgres psql -c "DROP DATABASE IF EXISTS coltura_prod;"
|
||||||
|
sudo -u postgres pg_restore -C -d postgres "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# 6. Lever maintenance
|
||||||
|
rm -f /var/www/coltura/maintenance.on
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regles de comportement pour le Claude prod
|
||||||
|
|
||||||
|
- **Ne jamais skipper le backup** (etape 2).
|
||||||
|
- **Demander confirmation utilisateur** avant : `DROP DATABASE`, `rm -rf`, et avant de lever le mode maintenance final.
|
||||||
|
- **Une seule operation destructive a la fois**, attendre le retour utilisateur entre chaque.
|
||||||
|
- **Logger systematiquement** la sortie des commandes critiques (pg_dump, docker compose up, nginx -t / reload).
|
||||||
|
- **Si une etape echoue**, NE PAS continuer — declencher le rollback.
|
||||||
|
- **Ne commit rien** sur le repo depuis le serveur prod.
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## Résumé de la PR
|
## Résumé de la PR
|
||||||
|
|
||||||
Cette PR restructure Coltura (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
|
Cette PR restructure Starseed (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
|
||||||
|
|
||||||
- **Backend** : introduction de bounded contexts (`Module/Core`, `Module/Commercial`) avec séparation Domain / Application / Infrastructure
|
- **Backend** : introduction de bounded contexts (`Module/Core`, `Module/Commercial`) avec séparation Domain / Application / Infrastructure
|
||||||
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
|
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
|
||||||
@@ -36,9 +36,9 @@ Cette PR restructure Coltura (CRM/ERP) en **architecture modulaire DDD** (Domain
|
|||||||
Liste des évolutions du projet Ferme
|
Liste des évolutions du projet Ferme
|
||||||
```
|
```
|
||||||
|
|
||||||
Ce fichier appartient à **Coltura**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
Ce fichier appartient à **Starseed**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
|
||||||
|
|
||||||
**Correction** : Remplacer "Ferme" par "Coltura".
|
**Correction** : Remplacer "Ferme" par "Starseed".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,10 +76,10 @@ Mais la seule page du module commercial est `frontend/modules/commercial/pages/c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Sévérité** | Majeure |
|
| **Sévérité** | Majeure |
|
||||||
| **Fichier** | `infra/dev/.env.docker` |
|
| **Fichier** | `infra/dev/.env.docker` |
|
||||||
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / 3003 / **5436**" |
|
| **Règle violée** | Workspace `CLAUDE.md` : "Starseed — 8083 / 3003 / **5436**" |
|
||||||
| **Confiance** | 75/100 |
|
| **Confiance** | 75/100 |
|
||||||
|
|
||||||
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Coltura est `5436`.
|
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Starseed est `5436`.
|
||||||
|
|
||||||
**Impact** : Tout développeur qui suit les ports documentés (ou qui utilise des scripts basés sur ces ports) ne pourra pas se connecter à la base.
|
**Impact** : Tout développeur qui suit les ports documentés (ou qui utilise des scripts basés sur ces ports) ne pourra pas se connecter à la base.
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ Mais la seule page du module commercial est `frontend/modules/commercial/pages/c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Sévérité** | Majeure |
|
| **Sévérité** | Majeure |
|
||||||
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
|
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
|
||||||
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
|
| **Règle violée** | Workspace `CLAUDE.md` : "Starseed — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
|
||||||
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
|
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
|
||||||
|
|
||||||
**Constat** :
|
**Constat** :
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ services:
|
|||||||
- "8083:80"
|
- "8083:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:ro
|
- ./:/var/www/html:ro
|
||||||
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/coltura.conf:ro
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`.
|
- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`.
|
||||||
- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`.
|
- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`.
|
||||||
- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes.
|
- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes.
|
||||||
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
|
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
|
||||||
- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`.
|
- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`.
|
||||||
- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`.
|
- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`.
|
||||||
- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`.
|
- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`.
|
||||||
@@ -31,30 +31,30 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
|
|
||||||
### Domaine - Entités
|
### Domaine - Entités
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
|
||||||
|
|
||||||
### Domaine - Repositories
|
### Domaine - Repositories
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
|
||||||
|
|
||||||
### Domaine - Exceptions
|
### Domaine - Exceptions
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
|
||||||
|
|
||||||
### Infrastructure - Doctrine
|
### Infrastructure - Doctrine
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
|
||||||
|
|
||||||
### Infrastructure - Doctrine Migrations
|
### Infrastructure - Doctrine Migrations
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
|
||||||
|
|
||||||
### Infrastructure - Console
|
### Infrastructure - Console
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
|
||||||
|
|
||||||
### Infrastructure - DataFixtures
|
### Infrastructure - DataFixtures
|
||||||
|
|
||||||
@@ -62,12 +62,12 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
|
|
||||||
### Constantes domaine
|
### Constantes domaine
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
|
||||||
```php
|
```php
|
||||||
public static function permissions(): array
|
public static function permissions(): array
|
||||||
{
|
{
|
||||||
@@ -80,17 +80,17 @@ Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le sto
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo).
|
La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo).
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
|
||||||
- `/home/matthieu/dev_malio/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
- `/home/matthieu/dev_malio/Starseed/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
|
||||||
```yaml
|
```yaml
|
||||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
||||||
```
|
```
|
||||||
La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire.
|
La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire.
|
||||||
- `/home/matthieu/dev_malio/Coltura/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
|
- `/home/matthieu/dev_malio/Starseed/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
|
||||||
|
|
||||||
## 5. Schéma cible — mappings Doctrine
|
## 5. Schéma cible — mappings Doctrine
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ Etat final attendu :
|
|||||||
|
|
||||||
## 6. Plan de migration Doctrine
|
## 6. Plan de migration Doctrine
|
||||||
|
|
||||||
La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
La migration doit etre implementée dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
|
||||||
|
|
||||||
**Workflow recommande** :
|
**Workflow recommande** :
|
||||||
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
|
||||||
@@ -287,7 +287,7 @@ Cas couverts explicitement :
|
|||||||
Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec :
|
Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it db-coltura psql -U malio -d coltura -c '\d "user"'
|
docker exec -it db-starseed psql -U malio -d starseed -c '\d "user"'
|
||||||
```
|
```
|
||||||
|
|
||||||
- Si `roles | json` : le SQL ci-dessus fonctionne tel quel.
|
- Si `roles | json` : le SQL ci-dessus fonctionne tel quel.
|
||||||
@@ -306,11 +306,11 @@ Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable
|
|||||||
|
|
||||||
## 7. Algorithme sync-permissions
|
## 7. Algorithme sync-permissions
|
||||||
|
|
||||||
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
|
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
|
||||||
|
|
||||||
### Source de verite
|
### Source de verite
|
||||||
|
|
||||||
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Coltura/config/modules.php`.
|
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Starseed/config/modules.php`.
|
||||||
- Chaque classe module active peut exposer `public static function permissions(): array`.
|
- Chaque classe module active peut exposer `public static function permissions(): array`.
|
||||||
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
|
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ Garde anti-typo : le sync command verifie que chaque `code` commence obligatoire
|
|||||||
```text
|
```text
|
||||||
begin transaction
|
begin transaction
|
||||||
|
|
||||||
load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php
|
load active module classes from /home/matthieu/dev_malio/Starseed/config/modules.php
|
||||||
desired_permissions = empty map keyed by code
|
desired_permissions = empty map keyed by code
|
||||||
|
|
||||||
for each module class:
|
for each module class:
|
||||||
@@ -439,7 +439,7 @@ Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declarat
|
|||||||
|
|
||||||
## 9. Fixtures mises à jour
|
## 9. Fixtures mises à jour
|
||||||
|
|
||||||
Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
Le fichier cible reste `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
|
||||||
|
|
||||||
### Principe cle : decouplage via `is_admin`
|
### Principe cle : decouplage via `is_admin`
|
||||||
|
|
||||||
@@ -519,7 +519,7 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
|||||||
- Risque de perte de donnees pendant la suppression de la colonne `user.roles`.
|
- Risque de perte de donnees pendant la suppression de la colonne `user.roles`.
|
||||||
- Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes.
|
- Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes.
|
||||||
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
|
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
|
||||||
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
|
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
|
||||||
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
|
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
|
||||||
- Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire.
|
- Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire.
|
||||||
- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`.
|
- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`.
|
||||||
@@ -533,12 +533,12 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
|||||||
|
|
||||||
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
|
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
|
||||||
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
|
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
|
||||||
3. Faire evoluer `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
|
3. Faire evoluer `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
|
||||||
4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules.
|
4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules.
|
||||||
5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
5. Ajouter la commande `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
|
||||||
6. Ecrire la migration `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
|
6. Ecrire la migration `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
|
||||||
7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
7. Mettre a jour `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
|
||||||
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`.
|
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Starseed/config/services.yaml`.
|
||||||
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
|
||||||
|
|
||||||
## 13. Critères d'acceptation (DoD)
|
## 13. Critères d'acceptation (DoD)
|
||||||
@@ -553,4 +553,4 @@ Les tests d'integration migration up/down exigent une base de test dediee avec u
|
|||||||
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
|
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
|
||||||
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
|
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
|
||||||
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
|
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
|
||||||
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Starseed/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.
|
||||||
|
|||||||
@@ -38,28 +38,28 @@ Le ticket n'introduit **aucune logique d'autorisation metier** : toute la verifi
|
|||||||
|
|
||||||
### Infrastructure - Processors
|
### Infrastructure - Processors
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessor.php`
|
||||||
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
|
Decorator de `ApiPlatform\Doctrine\Common\State\PersistProcessor` et `RemoveProcessor`. Charge de la garde `ensureDeletable()` et de la protection des champs immuables sur un role systeme.
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||||
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
|
Decorator de `PersistProcessor` specifique a l'operation `PATCH /api/users/{id}/rbac`. Persiste les mutations `isAdmin`, `roles`, `directPermissions` sans passer par `UserPasswordHasherProcessor`.
|
||||||
|
|
||||||
### Tests unitaires
|
### Tests unitaires
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/RoleProcessorTest.php`
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessorTest.php`
|
||||||
|
|
||||||
### Tests fonctionnels
|
### Tests fonctionnels
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/PermissionApiTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/PermissionApiTest.php`
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/RoleApiTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/RoleApiTest.php`
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserRbacApiTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/UserRbacApiTest.php`
|
||||||
|
|
||||||
## 4. Fichiers a modifier
|
## 4. Fichiers a modifier
|
||||||
|
|
||||||
### Entite `Permission`
|
### Entite `Permission`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php`
|
||||||
|
|
||||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection` + `Get` uniquement.
|
||||||
- Normalization context : groupe `permission:read` uniquement.
|
- Normalization context : groupe `permission:read` uniquement.
|
||||||
@@ -89,7 +89,7 @@ Extrait attendu :
|
|||||||
|
|
||||||
### Entite `Role`
|
### Entite `Role`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php`
|
||||||
|
|
||||||
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
- Ajouter l'attribut `#[ApiResource]` avec operations `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
||||||
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
- Normalization context : `role:read`. Denormalization context : `role:write`.
|
||||||
@@ -107,7 +107,7 @@ Extrait attendu :
|
|||||||
|
|
||||||
### Entite `User`
|
### Entite `User`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php`
|
||||||
|
|
||||||
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
- Ajouter dans la liste des operations `ApiResource` existantes une operation dediee :
|
||||||
|
|
||||||
|
|||||||
@@ -39,50 +39,50 @@ A l'issue de ce ticket, l'application dispose d'un systeme d'autorisation applic
|
|||||||
|
|
||||||
### Domaine - Securite
|
### Domaine - Securite
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Security/AdminHeadcountGuard.php`
|
||||||
Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation.
|
Service domaine encapsulant l'invariant "au moins un admin reste apres l'operation". Depend uniquement de `UserRepositoryInterface::countAdmins()`. Aucune dependance infrastructure, testable en isolation.
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Exception/LastAdminProtectionException.php`
|
||||||
Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors.
|
Exception metier levee par le guard. Traduite en `BadRequestHttpException` (400) dans les processors.
|
||||||
|
|
||||||
### Infrastructure - Security
|
### Infrastructure - Security
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Security/PermissionVoter.php`
|
||||||
Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`.
|
Voter Symfony etendant `Symfony\Component\Security\Core\Authorization\Voter\Voter`. Decouvert automatiquement par `autoconfigure: true`.
|
||||||
|
|
||||||
### Infrastructure - Processors
|
### Infrastructure - Processors
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
- `/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessor.php`
|
||||||
Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`.
|
Decorateur de `RemoveProcessor` cible sur `DELETE /api/users/{id}`. Appelle `AdminHeadcountGuard` avant de deleguer. Meme pattern qu'`UserRbacProcessor`/`RoleProcessor` : `final class`, `#[Autowire]` sur l'inner, `LogicException` fail-fast si le type entrant n'est pas `User`.
|
||||||
|
|
||||||
### Frontend - Composable
|
### Frontend - Composable
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/usePermissions.ts`
|
- `/home/matthieu/dev_malio/Starseed/frontend/shared/composables/usePermissions.ts`
|
||||||
Composable stateless qui lit `useAuthStore().user`. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
|
Composable stateless qui lit `useAuthStore().user`. Pas de fetch propre, pas de reset (le cycle de vie est porte par l'auth store).
|
||||||
|
|
||||||
### Tests unitaires PHP
|
### Tests unitaires PHP
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/Security/PermissionVoterTest.php`
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Domain/Security/AdminHeadcountGuardTest.php`
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserProcessorTest.php`
|
||||||
|
|
||||||
### Tests fonctionnels PHP
|
### Tests fonctionnels PHP
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/MeApiTest.php` (si absent — sinon extension)
|
||||||
Couvre l'enrichissement du payload `/api/me`.
|
Couvre l'enrichissement du payload `/api/me`.
|
||||||
- `/home/matthieu/dev_malio/Coltura/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
- `/home/matthieu/dev_malio/Starseed/tests/Module/Core/Api/UserApiTest.php` (si absent — sinon extension)
|
||||||
Couvre la garde "dernier admin global" sur `DELETE /api/users/{id}`.
|
Couvre la garde "dernier admin global" sur `DELETE /api/users/{id}`.
|
||||||
|
|
||||||
### Tests frontend
|
### Tests frontend
|
||||||
|
|
||||||
- `/home/matthieu/dev_malio/Coltura/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
- `/home/matthieu/dev_malio/Starseed/frontend/shared/composables/__tests__/usePermissions.test.ts`
|
||||||
Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). A verifier au debut de la task frontend.
|
Vitest. Emplacement a adapter si le projet Nuxt a une autre convention (colocalise avec un fichier `.spec.ts`, ou repertoire `tests/`). A verifier au debut de la task frontend.
|
||||||
|
|
||||||
## 4. Fichiers a modifier
|
## 4. Fichiers a modifier
|
||||||
|
|
||||||
### `CoreModule.php`
|
### `CoreModule.php`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/CoreModule.php`
|
||||||
|
|
||||||
Ajouter une cinquieme entree au catalogue :
|
Ajouter une cinquieme entree au catalogue :
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ La commande `app:sync-permissions` creera automatiquement `core.roles.view` a la
|
|||||||
|
|
||||||
### Entite `Permission`
|
### Entite `Permission`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Permission.php`
|
||||||
|
|
||||||
Remplacer les 2 gardes placeholder :
|
Remplacer les 2 gardes placeholder :
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ Supprimer les commentaires `// TODO ticket #345`.
|
|||||||
|
|
||||||
### Entite `Role`
|
### Entite `Role`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/Role.php`
|
||||||
|
|
||||||
Remplacer les 5 gardes placeholder :
|
Remplacer les 5 gardes placeholder :
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ Supprimer les commentaires `// TODO ticket #345`.
|
|||||||
|
|
||||||
### Entite `User`
|
### Entite `User`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Entity/User.php`
|
||||||
|
|
||||||
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
Remplacer les 6 gardes `ROLE_ADMIN` restantes :
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ Supprimer tous les commentaires `// TODO ticket #345` rencontres.
|
|||||||
|
|
||||||
### `UserRepositoryInterface`
|
### `UserRepositoryInterface`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||||
|
|
||||||
Ajouter la methode :
|
Ajouter la methode :
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ public function countAdmins(): int;
|
|||||||
|
|
||||||
### `DoctrineUserRepository`
|
### `DoctrineUserRepository`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php`
|
||||||
|
|
||||||
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
Implementer `countAdmins()` via un `QueryBuilder` simple :
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ public function countAdmins(): int
|
|||||||
|
|
||||||
### `UserRbacProcessor`
|
### `UserRbacProcessor`
|
||||||
|
|
||||||
`/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
`/home/matthieu/dev_malio/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php`
|
||||||
|
|
||||||
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
Ajouter la dependance `AdminHeadcountGuard` et l'invoquer **apres** la garde auto-suicide existante, **avant** de deleguer au persist processor. Supprimer le `TODO ticket #345` du docblock.
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
|||||||
|
|
||||||
### IN
|
### IN
|
||||||
|
|
||||||
- Creer le module `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`.
|
- Creer le module `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` avec `ID = 'sites'`, `LABEL = 'Sites'`, `REQUIRED = false`, et une methode statique `permissions()` declarant les deux codes RBAC `sites.view` et `sites.manage`.
|
||||||
- Creer l'entite Doctrine `Site` avec `id`, `name` (unique), `city`, `postalCode`, `color`, `fullAddress`, `createdAt`, `updatedAt` et les contraintes de validation applicatives associees (NotBlank, Length, Regex hex `#RRGGBB`, Regex CP FR `^\d{5}$`, UniqueEntity).
|
- Creer l'entite Doctrine `Site` avec `id`, `name` (unique), `city`, `postalCode`, `color`, `fullAddress`, `createdAt`, `updatedAt` et les contraintes de validation applicatives associees (NotBlank, Length, Regex hex `#RRGGBB`, Regex CP FR `^\d{5}$`, UniqueEntity).
|
||||||
- Creer l'interface `SiteRepositoryInterface` et son implementation Doctrine `DoctrineSiteRepository`, avec un contrat CRUD complet (`findById`, `findByName`, `findAllOrderedByName`, `save`, `remove`) en anticipation du ticket 2.
|
- Creer l'interface `SiteRepositoryInterface` et son implementation Doctrine `DoctrineSiteRepository`, avec un contrat CRUD complet (`findById`, `findByName`, `findAllOrderedByName`, `save`, `remove`) en anticipation du ticket 2.
|
||||||
- Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x).
|
- Creer une migration Doctrine creant la table `site` avec son index unique `uniq_site_name`. La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/` au namespace racine `DoctrineMigrations` conformement a l'exception documentee dans `CLAUDE.md` (bug de tri alphabetique des migrations multi-namespaces dans Doctrine Migrations 3.x).
|
||||||
- Creer `SitesFixtures` creant trois sites de demonstration : `Chatellerault` (`#056CF2`), `Saint-Jean` (`#10B981`), `Pommevic` (`#F59E0B`). Fixtures idempotentes via lookup par nom lorsque le purger Doctrine est desactive.
|
- Creer `SitesFixtures` creant trois sites de demonstration : `Chatellerault` (`#056CF2`), `Saint-Jean` (`#10B981`), `Pommevic` (`#F59E0B`). Fixtures idempotentes via lookup par nom lorsque le purger Doctrine est desactive.
|
||||||
- Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Coltura/config/modules.php` pour l'activer par defaut.
|
- Enregistrer `SitesModule::class` dans `/home/m-tristan/workspace/Starseed/config/modules.php` pour l'activer par defaut.
|
||||||
- Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`).
|
- Declarer le mapping Doctrine du module dans `/home/m-tristan/workspace/Starseed/config/packages/doctrine.yaml` (inconditionnel, le mapping reste charge meme si le module est retire de `modules.php`).
|
||||||
- Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Coltura/config/services.yaml`.
|
- Enregistrer l'alias service `SiteRepositoryInterface → DoctrineSiteRepository` dans `/home/m-tristan/workspace/Starseed/config/services.yaml`.
|
||||||
- Ajouter deux suites de tests PHPUnit :
|
- Ajouter deux suites de tests PHPUnit :
|
||||||
- `SiteTest` (pure `TestCase`) pour le comportement de l'entite (constructeur, getters/setters, lifecycle `PreUpdate`).
|
- `SiteTest` (pure `TestCase`) pour le comportement de l'entite (constructeur, getters/setters, lifecycle `PreUpdate`).
|
||||||
- `SiteValidationTest` (`KernelTestCase`) pour la validation complete : regex hex, regex CP FR, NotBlank, Length, UniqueEntity via Doctrine.
|
- `SiteValidationTest` (`KernelTestCase`) pour la validation complete : regex hex, regex CP FR, NotBlank, Length, UniqueEntity via Doctrine.
|
||||||
@@ -25,7 +25,7 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
|||||||
### OUT
|
### OUT
|
||||||
|
|
||||||
- Ticket `#02` : relation `User ↔ Site` (FK ou ManyToMany selon decision UX), expose les sites de l'utilisateur courant via `/api/me` et propage l'autorisation au niveau des ressources decoupees par site.
|
- Ticket `#02` : relation `User ↔ Site` (FK ou ManyToMany selon decision UX), expose les sites de l'utilisateur courant via `/api/me` et propage l'autorisation au niveau des ressources decoupees par site.
|
||||||
- Ticket `#03` : integration dans la navbar Coltura (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2).
|
- Ticket `#03` : integration dans la navbar Starseed (selecteur de site actif, persistance du choix cote front, consommation du flux issu du ticket 2).
|
||||||
- Ticket `#04` : ecran d'administration CRUD des sites (page admin/sites, DataTable, drawer creation/edition, modale suppression, API Platform `Site` resource avec voters RBAC).
|
- Ticket `#04` : ecran d'administration CRUD des sites (page admin/sites, DataTable, drawer creation/edition, modale suppression, API Platform `Site` resource avec voters RBAC).
|
||||||
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
|
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
|
||||||
- Rattachement historique ou audit trail des modifications : hors scope.
|
- Rattachement historique ou audit trail des modifications : hors scope.
|
||||||
@@ -34,38 +34,38 @@ Le resultat attendu est un socle de persistance activable par tenant via `config
|
|||||||
|
|
||||||
### Domaine — Entité
|
### Domaine — Entité
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Entity/Site.php` : entite Doctrine porteuse des attributs metier (nom unique, ville, code postal FR, couleur hex, adresse complete multi-ligne) et des timestamps auto-maintenus via lifecycle callbacks.
|
||||||
|
|
||||||
### Domaine — Repository
|
### Domaine — Repository
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut).
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Repository/SiteRepositoryInterface.php` : contrat d'acces domaine a l'entite Site (CRUD applicatif ; l'acces API Platform du ticket 4 utilisera le provider Doctrine par defaut).
|
||||||
|
|
||||||
### Infrastructure — Doctrine
|
### Infrastructure — Doctrine
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.
|
||||||
|
|
||||||
### Infrastructure — Migration
|
### Infrastructure — Migration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp>.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces).
|
- `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp>.php` : migration racine (namespace `DoctrineMigrations`) qui cree la table `site` et son index unique. Emplacement racine et non modulaire, cf. exception documentee dans `CLAUDE.md` (bug Doctrine 3.x sur le tri alphabetique des migrations multi-namespaces).
|
||||||
|
|
||||||
### Infrastructure — DataFixtures
|
### Infrastructure — DataFixtures
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket).
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : fixture Doctrine seedant les 3 sites de demonstration. Ne declare pas de `DependentFixtureInterface` (aucune dependance a AppFixtures dans ce ticket).
|
||||||
|
|
||||||
### Module — Declaration
|
### Module — Declaration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` : marker class du module avec `ID`, `LABEL`, `REQUIRED` et `permissions()`. Meme pattern que `CoreModule`.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`.
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Domain/Entity/SiteTest.php` : tests unitaires purs (`TestCase`) couvrant constructeur, getters, setters et lifecycle `PreUpdate`.
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test.
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Domain/Entity/SiteValidationTest.php` : tests de validation (`KernelTestCase`) couvrant regex hex, regex CP FR, NotBlank, Length sur tous les champs, et `UniqueEntity` via la DB de test.
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste).
|
- `/home/m-tristan/workspace/Starseed/config/modules.php` : ajouter `App\Module\Sites\SitesModule::class` dans le tableau de retour. Le module est actif par defaut. Le commenter suffit a le desactiver sans autre intervention (les permissions deviendront orphelines a la prochaine sync mais la table reste).
|
||||||
- `/home/m-tristan/workspace/Coltura/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine).
|
- `/home/m-tristan/workspace/Starseed/config/packages/doctrine.yaml` : ajouter une mapping `Sites:` alignee sur le pattern du module `Core:`. Le mapping est inconditionnel : il reste declare meme si `SitesModule::class` est retire de `modules.php`. Le commentaire doit etre explicite sur cette decoupe (activation fonctionnelle via `modules.php`, structure DB via la mapping Doctrine).
|
||||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants.
|
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : ajouter l'alias `App\Module\Sites\Domain\Repository\SiteRepositoryInterface` → `App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository`. Pattern aligne sur les trois aliases Core existants.
|
||||||
|
|
||||||
## 5. Schéma cible — mapping Doctrine
|
## 5. Schéma cible — mapping Doctrine
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ Sites:
|
|||||||
|
|
||||||
## 6. Plan de migration Doctrine
|
## 6. Plan de migration Doctrine
|
||||||
|
|
||||||
La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp>.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine.
|
La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp>.php` au namespace racine `DoctrineMigrations`, conformement a l'exception documentee dans `CLAUDE.md`. Tant que le bug de tri alphabetique des `MigrationsComparator` multi-namespaces n'est pas resolu (via un comparator custom ou un upgrade Doctrine), toute migration d'initialisation (creation de table sur base vide) reste au namespace racine.
|
||||||
|
|
||||||
### `up()` — ordre des instructions
|
### `up()` — ordre des instructions
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ Trois sites de demonstration, avec des couleurs distinctes suffisamment contrast
|
|||||||
|
|
||||||
| Nom | Ville | CP | Couleur | Commentaire |
|
| Nom | Ville | CP | Couleur | Commentaire |
|
||||||
|-----|-------|-----|---------|-------------|
|
|-----|-------|-----|---------|-------------|
|
||||||
| Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Coltura). |
|
| Chatellerault | Chatellerault | 86100 | `#056CF2` | Couleur imposee par le ticket (bleu Starseed). |
|
||||||
| Saint-Jean | Saint-Jean-de-Sauves | 86330 | `#10B981` | Vert emeraude (contraste avec le bleu). |
|
| Saint-Jean | Saint-Jean-de-Sauves | 86330 | `#10B981` | Vert emeraude (contraste avec le bleu). |
|
||||||
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |
|
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |
|
||||||
|
|
||||||
|
|||||||
@@ -40,70 +40,70 @@ Le resultat attendu est un module Sites utilisable de bout en bout cote admin (c
|
|||||||
|
|
||||||
### Backend — Module Sites
|
### Backend — Module Sites
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` : exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Exception/SiteNotAuthorizedException.php` : exception domaine levee si un user tente de switcher vers un site qui ne fait pas partie de ses sites autorises. Porte un message i18n-able et le code du site cible.
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php` : ressource API Platform **virtuelle** (pas de mapping Doctrine, pas de `#[ORM\Entity]`). Sert uniquement a porter l'operation `Patch` `/me/current-site`. Expose une propriete `site: Site` en denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupe `me:read`.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Resource/CurrentSiteResource.php` : ressource API Platform **virtuelle** (pas de mapping Doctrine, pas de `#[ORM\Entity]`). Sert uniquement a porter l'operation `Patch` `/me/current-site`. Expose une propriete `site: Site` en denormalisation pour recevoir l'IRI du site cible, et re-expose l'user courant en normalisation via le groupe `me:read`.
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php` : processor dedie a l'operation de switch. Valide l'appartenance du site aux `user.sites`, positionne `user.currentSite`, flush, retourne l'user.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/CurrentSiteProcessor.php` : processor dedie a l'operation de switch. Valide l'appartenance du site aux `user.sites`, positionne `user.currentSite`, flush, retourne l'user.
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php` : listener Kernel qui convertit `SiteNotAuthorizedException` en `ForbiddenHttpException` (403) avec un code i18n stable (cf. pattern `SystemRoleDeletionException` du module Core dans les tickets RBAC precedents).
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/EventListener/SiteNotAuthorizedExceptionListener.php` : listener Kernel qui convertit `SiteNotAuthorizedException` en `ForbiddenHttpException` (403) avec un code i18n stable (cf. pattern `SystemRoleDeletionException` du module Core dans les tickets RBAC precedents).
|
||||||
|
|
||||||
### Backend — Migration
|
### Backend — Migration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php` : migration au namespace racine `DoctrineMigrations` (cf. exception Doctrine documentee dans `CLAUDE.md`). Cree la table `user_site` et la colonne `user.current_site_id` avec les FKs et cascades appropriees.
|
- `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp2>.php` : migration au namespace racine `DoctrineMigrations` (cf. exception Doctrine documentee dans `CLAUDE.md`). Cree la table `user_site` et la colonne `user.current_site_id` avec les FKs et cascades appropriees.
|
||||||
|
|
||||||
### Backend — Tests API
|
### Backend — Tests API
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteApiTest.php` : CRUD complet `/api/sites` avec matrices RBAC (admin, user avec `sites.view`, user avec `sites.manage`, user sans permission).
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/SiteApiTest.php` : CRUD complet `/api/sites` avec matrices RBAC (admin, user avec `sites.view`, user avec `sites.manage`, user sans permission).
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php` : PATCH `/me/current-site` (OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide).
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/CurrentSiteSwitchApiTest.php` : PATCH `/me/current-site` (OK avec site autorise, 403 avec site non autorise, 400 avec IRI invalide).
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/MeEndpointSitesTest.php` : `/api/me` expose bien `sites` et `currentSite` en objets. User sans site : `sites: []`, `currentSite: null`.
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/MeEndpointSitesTest.php` : `/api/me` expose bien `sites` et `currentSite` en objets. User sans site : `sites: []`, `currentSite: null`.
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Api/SiteCascadeTest.php` : suppression d'un site `X` → toutes les lignes `user_site` referencant `X` sont supprimees, tous les users ayant `X` en `currentSite` voient leur `currentSite` repasser a `NULL`.
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Api/SiteCascadeTest.php` : suppression d'un site `X` → toutes les lignes `user_site` referencant `X` sont supprimees, tous les users ayant `X` en `currentSite` voient leur `currentSite` repasser a `NULL`.
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Core/Api/UserRbacSitesApiTest.php` : extension du endpoint `/api/users/{id}/rbac` — ajout de `sites: []` dans le payload, retrait du `currentSite` quand le site retire etait le courant.
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Core/Api/UserRbacSitesApiTest.php` : extension du endpoint `/api/users/{id}/rbac` — ajout de `sites: []` dans le payload, retrait du `currentSite` quand le site retire etait le courant.
|
||||||
|
|
||||||
### Frontend — Module Sites (nouveau layer)
|
### Frontend — Module Sites (nouveau layer)
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/nuxt.config.ts` : marker de layer Nuxt (vide). Declenche l'auto-detection par `nuxt.config.ts` racine.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/nuxt.config.ts` : marker de layer Nuxt (vide). Declenche l'auto-detection par `nuxt.config.ts` racine.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.vue` : page `/admin/sites`. Reutilise les composants Malio UI (`MalioDataTable`, `MalioButton`, `MalioInputText`, `MalioInputTextArea`). Pattern identique a `frontend/modules/core/pages/admin/roles.vue`.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/pages/admin/sites.vue` : page `/admin/sites`. Reutilise les composants Malio UI (`MalioDataTable`, `MalioButton`, `MalioInputText`, `MalioInputTextArea`). Pattern identique a `frontend/modules/core/pages/admin/roles.vue`.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDrawer.vue` : drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteDrawer.vue` : drawer creation/edition. Formulaire 5 champs (nom, ville, CP, couleur avec preview puce, adresse). Valide cote front sur le submit avant d'envoyer.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteDeleteModal.vue` : modale de confirmation suppression. Pattern aligne sur `RoleDeleteModal.vue`.
|
||||||
|
|
||||||
### Frontend — Types partages
|
### Frontend — Types partages
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : types `Site`, `SiteInput`. Pattern identique a `frontend/shared/types/rbac.ts`.
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/sites.ts` : types `Site`, `SiteInput`. Pattern identique a `frontend/shared/types/rbac.ts`.
|
||||||
|
|
||||||
### Tests frontend (optionnels mais recommandes)
|
### Tests frontend (optionnels mais recommandes)
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/pages/admin/sites.spec.ts` : smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/pages/admin/sites.spec.ts` : smoke test Vitest (rendu + clic bouton "Nouveau site" ouvre le drawer).
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
### Backend — Module Core
|
### Backend — Module Core
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Domain/Entity/User.php` :
|
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Domain/Entity/User.php` :
|
||||||
- Ajouter `private Collection $sites;` (M2M, `fetch: EAGER`, `JoinTable: user_site`), groupes `me:read`, `user:list`, `user:rbac:read`, `user:rbac:write`.
|
- Ajouter `private Collection $sites;` (M2M, `fetch: EAGER`, `JoinTable: user_site`), groupes `me:read`, `user:list`, `user:rbac:read`, `user:rbac:write`.
|
||||||
- Ajouter `private ?Site $currentSite = null;` (M2O, `fetch: EAGER`, `onDelete: 'SET NULL'`), groupe `me:read`.
|
- Ajouter `private ?Site $currentSite = null;` (M2O, `fetch: EAGER`, `onDelete: 'SET NULL'`), groupe `me:read`.
|
||||||
- Initialiser `$this->sites = new ArrayCollection();` dans le constructeur.
|
- Initialiser `$this->sites = new ArrayCollection();` dans le constructeur.
|
||||||
- Ajouter les accesseurs `getSites()`, `addSite(Site)`, `removeSite(Site)`, `hasSite(Site)`, `getCurrentSite()`, `setCurrentSite(?Site)`.
|
- Ajouter les accesseurs `getSites()`, `addSite(Site)`, `removeSite(Site)`, `hasSite(Site)`, `getCurrentSite()`, `setCurrentSite(?Site)`.
|
||||||
- **Important** : `import` direct `App\Module\Sites\Domain\Entity\Site`. Ce ticket assume le couplage Core → Sites au niveau code PHP (cf. Risque 1).
|
- **Important** : `import` direct `App\Module\Sites\Domain\Entity\Site`. Ce ticket assume le couplage Core → Sites au niveau code PHP (cf. Risque 1).
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` :
|
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserRbacProcessor.php` :
|
||||||
- Etendre le contrat d'entree pour accepter le champ `sites` (collection d'IRIs denormalisees en `Collection<Site>`).
|
- Etendre le contrat d'entree pour accepter le champ `sites` (collection d'IRIs denormalisees en `Collection<Site>`).
|
||||||
- Apres l'application des roles et permissions directes, detecter si `currentSite` du user cible n'est plus dans la nouvelle collection `sites` → basculer `currentSite` a `null`.
|
- Apres l'application des roles et permissions directes, detecter si `currentSite` du user cible n'est plus dans la nouvelle collection `sites` → basculer `currentSite` a `null`.
|
||||||
- Conserver toutes les gardes existantes (auto-suicide admin, dernier admin global).
|
- Conserver toutes les gardes existantes (auto-suicide admin, dernier admin global).
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` :
|
- `/home/m-tristan/workspace/Starseed/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` :
|
||||||
- Declarer l'implementation `DependentFixtureInterface` avec `getDependencies(): [SitesFixtures::class]` (inversion de l'ordre actuel : AppFixtures doit tourner **apres** SitesFixtures pour pouvoir reference les sites).
|
- Declarer l'implementation `DependentFixtureInterface` avec `getDependencies(): [SitesFixtures::class]` (inversion de l'ordre actuel : AppFixtures doit tourner **apres** SitesFixtures pour pouvoir reference les sites).
|
||||||
- Rattacher chaque user a au moins un site : `admin` a tous les sites (`Chatellerault`, `Saint-Jean`, `Pommevic`), `alice` a `Chatellerault`, `bob` a `Saint-Jean`.
|
- Rattacher chaque user a au moins un site : `admin` a tous les sites (`Chatellerault`, `Saint-Jean`, `Pommevic`), `alice` a `Chatellerault`, `bob` a `Saint-Jean`.
|
||||||
- Positionner `currentSite` : `admin.currentSite = Chatellerault`, `alice.currentSite = Chatellerault`, `bob.currentSite = Saint-Jean`.
|
- Positionner `currentSite` : `admin.currentSite = Chatellerault`, `alice.currentSite = Chatellerault`, `bob.currentSite = Saint-Jean`.
|
||||||
|
|
||||||
### Backend — Module Sites
|
### Backend — Module Sites
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Domain/Entity/Site.php` :
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Domain/Entity/Site.php` :
|
||||||
- Ajouter les attributs `#[ApiResource]` + operations (cf. section 5 Schema).
|
- Ajouter les attributs `#[ApiResource]` + operations (cf. section 5 Schema).
|
||||||
- Ajouter les groupes de serialisation `site:read`, `site:write`, `me:read` sur les proprietes scalaires.
|
- Ajouter les groupes de serialisation `site:read`, `site:write`, `me:read` sur les proprietes scalaires.
|
||||||
- Ajouter la relation inverse `private Collection $users;` (M2M mappedBy=`sites`), **sans** groupe de serialisation (pas d'exposition API cote Site).
|
- Ajouter la relation inverse `private Collection $users;` (M2M mappedBy=`sites`), **sans** groupe de serialisation (pas d'exposition API cote Site).
|
||||||
- Initialiser `$this->users = new ArrayCollection();` dans le constructeur.
|
- Initialiser `$this->users = new ArrayCollection();` dans le constructeur.
|
||||||
- Ajouter les accesseurs `getUsers()` pour les besoins metier (count / cascade manuel si besoin).
|
- Ajouter les accesseurs `getUsers()` pour les besoins metier (count / cascade manuel si besoin).
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/DataFixtures/SitesFixtures.php` : aucun changement de contenu, mais verifier que la fixture n'est plus en bout de chaine de dependance (AppFixtures depend d'elle maintenant).
|
||||||
|
|
||||||
### Backend — Configuration
|
### Backend — Configuration
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/config/sidebar.php` : inserer l'entree `Sites` dans la section `sidebar.general.section` entre `sidebar.core.users` et `sidebar.general.logout` :
|
- `/home/m-tristan/workspace/Starseed/config/sidebar.php` : inserer l'entree `Sites` dans la section `sidebar.general.section` entre `sidebar.core.users` et `sidebar.general.logout` :
|
||||||
```php
|
```php
|
||||||
[
|
[
|
||||||
'label' => 'sidebar.core.sites',
|
'label' => 'sidebar.core.sites',
|
||||||
@@ -113,18 +113,18 @@ Le resultat attendu est un module Sites utilisable de bout en bout cote admin (c
|
|||||||
'permission' => 'sites.view',
|
'permission' => 'sites.view',
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : aucun changement requis. `CurrentSiteProcessor`, `SiteNotAuthorizedExceptionListener` sont autoconfigures.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/components/UserRbacDrawer.vue` :
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/core/components/UserRbacDrawer.vue` :
|
||||||
- Charger `GET /api/sites?itemsPerPage=999` a l'ouverture du drawer (parallelement aux roles et permissions deja charges).
|
- Charger `GET /api/sites?itemsPerPage=999` a l'ouverture du drawer (parallelement aux roles et permissions deja charges).
|
||||||
- Ajouter une section `sidebar.admin.usersDrawer.sitesSection` sous la section permissions directes, avec un groupe de `MalioCheckbox` par site (ou un `MalioMultiSelect` si le composant existe dans `@malio/layer-ui`).
|
- Ajouter une section `sidebar.admin.usersDrawer.sitesSection` sous la section permissions directes, avec un groupe de `MalioCheckbox` par site (ou un `MalioMultiSelect` si le composant existe dans `@malio/layer-ui`).
|
||||||
- Etendre le payload `PATCH /api/users/{id}/rbac` avec `sites: Array<string>` (IRIs).
|
- Etendre le payload `PATCH /api/users/{id}/rbac` avec `sites: Array<string>` (IRIs).
|
||||||
- Auto-refresh de l'auth store apres save si `isSelfEdit` (deja present, conserver).
|
- Auto-refresh de l'auth store apres save si `isSelfEdit` (deja present, conserver).
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/rbac.ts` : ajouter le champ `sites: string[]` a `UserListItem` (IRIs de sites attaches).
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/rbac.ts` : ajouter le champ `sites: string[]` a `UserListItem` (IRIs de sites attaches).
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/stores/auth.ts` : le store auth expose deja `user` via `/api/me`. Aucune modification requise, les nouveaux champs `sites` et `currentSite` suivent automatiquement via la typologie — a condition de mettre a jour le type `UserData` dans `shared/types/` (ajouter `sites: Site[]` et `currentSite: Site | null`).
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/stores/auth.ts` : le store auth expose deja `user` via `/api/me`. Aucune modification requise, les nouveaux champs `sites` et `currentSite` suivent automatiquement via la typologie — a condition de mettre a jour le type `UserData` dans `shared/types/` (ajouter `sites: Site[]` et `currentSite: Site | null`).
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : cles
|
- `/home/m-tristan/workspace/Starseed/frontend/i18n/locales/fr.json` : cles
|
||||||
- `sidebar.core.sites` = "Sites".
|
- `sidebar.core.sites` = "Sites".
|
||||||
- `admin.sites.title`, `admin.sites.newSite`, `admin.sites.editSite`, `admin.sites.createSite`, `admin.sites.noSites`.
|
- `admin.sites.title`, `admin.sites.newSite`, `admin.sites.editSite`, `admin.sites.createSite`, `admin.sites.noSites`.
|
||||||
- `admin.sites.table.{name, city, postalCode, color, fullAddress}`.
|
- `admin.sites.table.{name, city, postalCode, color, fullAddress}`.
|
||||||
@@ -228,7 +228,7 @@ final class CurrentSiteResource
|
|||||||
|
|
||||||
## 6. Plan de migration Doctrine
|
## 6. Plan de migration Doctrine
|
||||||
|
|
||||||
La migration est placee dans `/home/m-tristan/workspace/Coltura/migrations/Version<timestamp2>.php` au namespace racine (cf. Risque 2 du ticket 1 et `CLAUDE.md`).
|
La migration est placee dans `/home/m-tristan/workspace/Starseed/migrations/Version<timestamp2>.php` au namespace racine (cf. Risque 2 du ticket 1 et `CLAUDE.md`).
|
||||||
|
|
||||||
### `up()` — ordre des instructions
|
### `up()` — ordre des instructions
|
||||||
|
|
||||||
|
|||||||
@@ -77,42 +77,42 @@ Resultat attendu : apres merge, un user avec ≥ 1 site voit une barre sous la n
|
|||||||
|
|
||||||
### Frontend — Module Sites (layer deja cree au ticket 2)
|
### Frontend — Module Sites (layer deja cree au ticket 2)
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/SiteSelector.vue` : wrapper Vue autour de `MalioSiteSelector`. Branche `useCurrentSite()`, gere l'optimistic update et les toasts.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/composables/useCurrentSite.ts` : composable global exposant l'etat `currentSite` / `availableSites`, les actions `switchSite`, `resetCurrentSite`, et un flag `switching: Ref<boolean>` pour desactiver le selecteur pendant une requete en vol.
|
||||||
|
|
||||||
### Frontend — Shared
|
### Frontend — Shared
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/composables/useModules.ts` : composable qui charge `/api/modules` et expose `isModuleActive(id: string): boolean`. Pattern aligne sur `useSidebar()` : ref singleton au niveau module, chargement idempotent, `resetModules()` expose pour le logout.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/color.ts` : fonctions utilitaires de couleur, au minimum :
|
||||||
- `parseHex(hex: string): { r: number; g: number; b: number }` — tolere la casse, rejette les formats hors `#RRGGBB`.
|
- `parseHex(hex: string): { r: number; g: number; b: number }` — tolere la casse, rejette les formats hors `#RRGGBB`.
|
||||||
- `getRelativeLuminance({r, g, b}): number` — formule WCAG standard.
|
- `getRelativeLuminance({r, g, b}): number` — formule WCAG standard.
|
||||||
- `getReadableTextColor(hex: string): 'black' | 'white'` — renvoie `'black'` si la luminance > 0.5, `'white'` sinon. Seuil simple, suffisant pour un CRM interne (pas WCAG AAA).
|
- `getReadableTextColor(hex: string): 'black' | 'white'` — renvoie `'black'` si la luminance > 0.5, `'white'` sinon. Seuil simple, suffisant pour un CRM interne (pas WCAG AAA).
|
||||||
|
|
||||||
### Frontend — Tests
|
### Frontend — Tests
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/composables/__tests__/useCurrentSite.spec.ts` : Vitest. Tests :
|
||||||
- `switchSite` met a jour l'etat localement avant la requete (optimistic).
|
- `switchSite` met a jour l'etat localement avant la requete (optimistic).
|
||||||
- Si la requete reussit, l'etat reste aligne.
|
- Si la requete reussit, l'etat reste aligne.
|
||||||
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
|
- Si la requete echoue, l'etat rollback a l'ancien `currentSite`.
|
||||||
- `resetCurrentSite` vide l'etat.
|
- `resetCurrentSite` vide l'etat.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/composables/__tests__/useModules.spec.ts` : Vitest. Tests `isModuleActive` apres chargement, `resetModules` vide l'etat.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Coltura) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/utils/__tests__/color.spec.ts` : Vitest. Jeu de donnees sur `getReadableTextColor` : `#000000` → white, `#FFFFFF` → black, `#056CF2` (bleu Starseed) → white, `#F59E0B` (ambre) → black, `#10B981` (vert) → black ou white selon seuil (a verifier). Tester aussi le rejet de formats invalides.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/sites/components/__tests__/SiteSelector.spec.ts` : smoke test Vitest.
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
|
- `/home/m-tristan/workspace/Starseed/frontend/package.json` : upgrade `@malio/layer-ui` vers la version qui inclut `MalioSiteSelector`. Commit du `package-lock.json` dans le meme changeset.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/user-data.ts` : ajouter les champs
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/user-data.ts` : ajouter les champs
|
||||||
```ts
|
```ts
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
currentSite: Site | null
|
currentSite: Site | null
|
||||||
```
|
```
|
||||||
Import du type `Site` depuis `./sites`. Note : si le type `Site` a deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dans `frontend/shared/types/sites.ts`.
|
Import du type `Site` depuis `./sites`. Note : si le type `Site` a deja ete introduit au ticket 2, reutiliser ; sinon, ce ticket le cree dans `frontend/shared/types/sites.ts`.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
|
- `/home/m-tristan/workspace/Starseed/frontend/shared/types/sites.ts` : si absent, creer avec l'interface `Site` (cf. section Schema ticket 2 pour la forme). Si present, aucune modification.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
|
- `/home/m-tristan/workspace/Starseed/frontend/app/layouts/default.vue` : integrer `SiteSelector` sous le header, avant `<main>`, dans le flex column. Rendu conditionnel via `v-if="showSiteSelector"` ou via un `defineAsyncComponent` chargement lazy si on veut eviter l'import statique quand le module est off.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
|
- `/home/m-tristan/workspace/Starseed/frontend/app/middleware/auth.global.ts` : ajouter le chargement de `useModules().loadModules()` apres `loadSidebar()`. Necessaire pour que `isModuleActive` soit resolu quand le layout se rend.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
|
- `/home/m-tristan/workspace/Starseed/frontend/modules/core/pages/logout.vue` : appeler `useCurrentSite().resetCurrentSite()` et `useModules().resetModules()` apres le `auth.logout()`, aligne sur le pattern `resetSidebar()` deja present.
|
||||||
- `/home/m-tristan/workspace/Coltura/frontend/i18n/locales/fr.json` : ajouter les cles
|
- `/home/m-tristan/workspace/Starseed/frontend/i18n/locales/fr.json` : ajouter les cles
|
||||||
```json
|
```json
|
||||||
"sites": {
|
"sites": {
|
||||||
"selector": {
|
"selector": {
|
||||||
|
|||||||
@@ -34,50 +34,50 @@ Le ticket livre aussi une documentation developpeur (`docs/modules/site-aware.md
|
|||||||
|
|
||||||
### Shared — Contrat
|
### Shared — Contrat
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Shared/Domain/Contract/SiteAwareInterface.php` : interface minimale. Depends uniquement du type `App\Module\Sites\Domain\Entity\Site`, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
|
- `/home/m-tristan/workspace/Starseed/src/Shared/Domain/Contract/SiteAwareInterface.php` : interface minimale. Depends uniquement du type `App\Module\Sites\Domain\Entity\Site`, qui est deja couple cote Core depuis le ticket 2 — le placement dans Shared n'introduit pas de nouvelle dependance transversale non souhaitee.
|
||||||
|
|
||||||
### Module Sites — Application
|
### Module Sites — Application
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Application/Service/CurrentSiteProvider.php` : service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retour `null` : pas d'user, `currentSite` null, module desactive.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Application/Service/CurrentSiteProvider.php` : service injecte partout ou le site courant doit etre lu (extensions, processor, futurs voters). Gere les trois cas de retour `null` : pas d'user, `currentSite` null, module desactive.
|
||||||
|
|
||||||
### Module Sites — Infrastructure
|
### Module Sites — Infrastructure
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php` : une seule classe, implementant a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un `getOneOrNullResult` null en 404).
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtension.php` : une seule classe, implementant a la fois `QueryCollectionExtensionInterface` et `QueryItemExtensionInterface`. Le comportement est identique pour les deux, modulo que l'item manque retourne 404 (API Platform converti un `getOneOrNullResult` null en 404).
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` : decorator sur le persist processor Doctrine. Injecte le site courant sur `$data` si applicable, puis delegue a `$persistProcessor`.
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` : decorator sur le persist processor Doctrine. Injecte le site courant sur `$data` si applicable, puis delegue a `$persistProcessor`.
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
- `/home/m-tristan/workspace/Starseed/docs/modules/site-aware.md` : guide developpeur (cf. contenu section 10).
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php` : tests d'integration (`KernelTestCase`) avec l'entite `FakeSiteAwareEntity` (declaree uniquement dans le dossier de tests). Verifie :
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteScopedQueryExtensionTest.php` : tests d'integration (`KernelTestCase`) avec l'entite `FakeSiteAwareEntity` (declaree uniquement dans le dossier de tests). Verifie :
|
||||||
- Le filtre s'applique sur une resource `SiteAware` quand le provider retourne un site.
|
- Le filtre s'applique sur une resource `SiteAware` quand le provider retourne un site.
|
||||||
- Le filtre est no-op si `SiteAware` mais provider null.
|
- Le filtre est no-op si `SiteAware` mais provider null.
|
||||||
- Le filtre est no-op si resource non `SiteAware`.
|
- Le filtre est no-op si resource non `SiteAware`.
|
||||||
- Le filtre est no-op si user a `sites.bypass_scope`.
|
- Le filtre est no-op si user a `sites.bypass_scope`.
|
||||||
- `totalItems` Hydra reflete bien le filtrage.
|
- `totalItems` Hydra reflete bien le filtrage.
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php` : tests unitaires (`TestCase` pur) avec mocks. Verifie :
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessorTest.php` : tests unitaires (`TestCase` pur) avec mocks. Verifie :
|
||||||
- `$data` SiteAware sans site → injection du site courant.
|
- `$data` SiteAware sans site → injection du site courant.
|
||||||
- `$data` SiteAware avec site deja positionne → pas d'overwrite.
|
- `$data` SiteAware avec site deja positionne → pas d'overwrite.
|
||||||
- `$data` non-SiteAware → delegation directe sans modification.
|
- `$data` non-SiteAware → delegation directe sans modification.
|
||||||
- Provider retourne null (module off ou user sans site) ET `$data` SiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
|
- Provider retourne null (module off ou user sans site) ET `$data` SiteAware sans site → BadRequestHttpException (400) "aucun site selectionne".
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php` : tests unitaires `TestCase`. Couvre :
|
- `/home/m-tristan/workspace/Starseed/tests/Module/Sites/Application/Service/CurrentSiteProviderTest.php` : tests unitaires `TestCase`. Couvre :
|
||||||
- User authentifie avec currentSite → retourne le Site.
|
- User authentifie avec currentSite → retourne le Site.
|
||||||
- User authentifie sans currentSite → null.
|
- User authentifie sans currentSite → null.
|
||||||
- Pas d'user → null.
|
- Pas d'user → null.
|
||||||
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
|
- Module desactive dans config/modules.php de test → null meme si user.currentSite existe.
|
||||||
- `/home/m-tristan/workspace/Coltura/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php` : entite Doctrine minimale (`id`, `name`, `site`) utilisee **uniquement** en tests. Mapping Doctrine declare via un `#[ORM\Entity]` mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. **Alternative** : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
|
- `/home/m-tristan/workspace/Starseed/tests/Fixtures/SiteAware/FakeSiteAwareEntity.php` : entite Doctrine minimale (`id`, `name`, `site`) utilisee **uniquement** en tests. Mapping Doctrine declare via un `#[ORM\Entity]` mais la table n'existe jamais en prod car la fixture n'est jamais chargee hors tests. **Alternative** : utiliser un schema DB dedie au dossier de tests, cree a la volee par un helper setUp. A trancher a l'implementation.
|
||||||
|
|
||||||
## 4. Fichiers à modifier
|
## 4. Fichiers à modifier
|
||||||
|
|
||||||
- `/home/m-tristan/workspace/Coltura/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
- `/home/m-tristan/workspace/Starseed/src/Module/Sites/SitesModule.php` : ajouter la permission `sites.bypass_scope` dans `permissions()` :
|
||||||
```php
|
```php
|
||||||
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
|
||||||
```
|
```
|
||||||
**Note importante** : la methode `permissions()` signale l'existence de la permission mais c'est la commande `app:sync-permissions` (inchangee) qui la positionne en base.
|
**Note importante** : la methode `permissions()` signale l'existence de la permission mais c'est la commande `app:sync-permissions` (inchangee) qui la positionne en base.
|
||||||
- `/home/m-tristan/workspace/Coltura/config/services.yaml` : aucun changement requis. `SiteScopedQueryExtension`, `SiteAwareInjectionProcessor` et `CurrentSiteProvider` sont autoconfigures via les `_defaults` du module. Le decorator du persist processor est declare via `#[AsDecorator]` ou via tag (cf. section 8).
|
- `/home/m-tristan/workspace/Starseed/config/services.yaml` : aucun changement requis. `SiteScopedQueryExtension`, `SiteAwareInjectionProcessor` et `CurrentSiteProvider` sont autoconfigures via les `_defaults` du module. Le decorator du persist processor est declare via `#[AsDecorator]` ou via tag (cf. section 8).
|
||||||
- `/home/m-tristan/workspace/Coltura/phpunit.dist.xml` : aucune modification requise si la config des fixtures de tests est autonome. Si `FakeSiteAwareEntity` necessite un mapping dedie, l'option la plus propre est un `doctrine.yaml.test` ajoute via `when@test`, sans polluer la config dev/prod (cf. Risque 3).
|
- `/home/m-tristan/workspace/Starseed/phpunit.dist.xml` : aucune modification requise si la config des fixtures de tests est autonome. Si `FakeSiteAwareEntity` necessite un mapping dedie, l'option la plus propre est un `doctrine.yaml.test` ajoute via `when@test`, sans polluer la config dev/prod (cf. Risque 3).
|
||||||
|
|
||||||
## 5. Contrat `SiteAwareInterface`
|
## 5. Contrat `SiteAwareInterface`
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@ A mitiger par un test qui genere une entite `FakeSiteAwareEntity` via un POST `a
|
|||||||
|
|
||||||
### Risque 8 — Doc developpeur en francais vs anglais
|
### Risque 8 — Doc developpeur en francais vs anglais
|
||||||
|
|
||||||
Le fichier `docs/modules/site-aware.md` s'adresse aux developpeurs de Coltura. Il est redige en **francais**, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
|
Le fichier `docs/modules/site-aware.md` s'adresse aux developpeurs de Starseed. Il est redige en **francais**, aligne sur la convention projet (CLAUDE.md : "commentaires en francais, code en anglais"). Aucun extrait de code ne doit etre traduit, seules les explications.
|
||||||
|
|
||||||
## 12. Plan de tests
|
## 12. Plan de tests
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M0
|
||||||
|
nom: "Gestion des catégories"
|
||||||
|
ecran: gestion-categories
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0
|
||||||
|
date_redaction: 2026-05-22
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: null # pas de Figma — UI admin standard
|
||||||
|
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT #1 ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee # V0 client validée le 22/05/2026
|
||||||
|
date: 2026-05-22
|
||||||
|
canal: ecrit
|
||||||
|
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||||
|
resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only."
|
||||||
|
trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown."
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_taskgroup_id: 22
|
||||||
|
lesstime_project_id: 6 # ERP / Starseed
|
||||||
|
statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 0 — Gestion des catégories (V0 front)
|
||||||
|
|
||||||
|
> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires).
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories »
|
||||||
|
- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture).
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant toutes les catégories existantes.
|
||||||
|
- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »).
|
||||||
|
- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »).
|
||||||
|
- Pas d'onglet, pas de pagination explicite (volumétrie cible faible).
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
| Action | Déclencheur | Comportement |
|
||||||
|
|---|---|---|
|
||||||
|
| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. |
|
||||||
|
| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). |
|
||||||
|
| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. |
|
||||||
|
|
||||||
|
> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable.
|
||||||
|
|
||||||
|
## Formulaire — Champs
|
||||||
|
|
||||||
|
Le formulaire (drawer) contient **2 champs**, tous deux obligatoires :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2–120). |
|
||||||
|
| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). |
|
||||||
|
|
||||||
|
> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0.
|
||||||
|
|
||||||
|
## Permissions par rôle
|
||||||
|
|
||||||
|
| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) |
|
||||||
|
| Bureau | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Compta | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Commerciale | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Usine | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (Starseed / `@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom).
|
||||||
|
- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact).
|
||||||
|
- **Input texte** : `<MalioInputText>` pour le champ Nom.
|
||||||
|
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
|
||||||
|
- **Bouton** : `<MalioButton>` (« + Ajouter », « Enregistrer », « Annuler »).
|
||||||
|
- **Toasts succès / erreur** : standards via `useApi()`.
|
||||||
|
|
||||||
|
## Points laissés ouverts par la V0 (résolus côté back)
|
||||||
|
|
||||||
|
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. |
|
||||||
|
| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). |
|
||||||
|
| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). |
|
||||||
|
| 4 | Volumétrie & pagination | **300 max** → pagination front (`<MalioDataTable>`), pas de pagination serveur. Tri serveur `name ASC` par défaut. |
|
||||||
|
| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. |
|
||||||
|
| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime générés
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6)
|
||||||
|
|
||||||
|
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||||
@@ -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>
|
||||||
@@ -62,6 +70,6 @@ watch(() => route.path, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
titleTemplate: (title) => title || 'Coltura',
|
titleTemplate: (title) => title || 'Starseed',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
/* Coltura - Custom styles */
|
/* Starseed - Custom styles */
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default await nuxt(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'coltura/custom-overrides',
|
name: 'starseed/custom-overrides',
|
||||||
rules: {
|
rules: {
|
||||||
// Indentation 4 espaces (convention CLAUDE.md)
|
// Indentation 4 espaces (convention CLAUDE.md)
|
||||||
'vue/html-indent': ['error', 4],
|
'vue/html-indent': ['error', 4],
|
||||||
|
|||||||
@@ -32,11 +32,14 @@
|
|||||||
},
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"admin": "Sites"
|
"admin": "Sites"
|
||||||
|
},
|
||||||
|
"catalog": {
|
||||||
|
"categories": "Gestion des catégories"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
"welcome": "Bienvenue sur Coltura"
|
"welcome": "Bienvenue sur Starseed"
|
||||||
},
|
},
|
||||||
"commercial": {
|
"commercial": {
|
||||||
"title": "Commercial",
|
"title": "Commercial",
|
||||||
@@ -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é",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,100 +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. -->
|
|
||||||
<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"
|
|
||||||
group-class="h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
|
|
||||||
supportera de maniere fiable des options a valeur string
|
|
||||||
(cf. note Lesstime CLAUDE.md). Exception documentee dans
|
|
||||||
CLAUDE.md (section "Composants formulaires"). -->
|
|
||||||
<div>
|
|
||||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
|
||||||
{{ t('audit.filters.action') }}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="actionValue"
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="">{{ t('audit.filters.all_actions') }}</option>
|
|
||||||
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</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"
|
||||||
@@ -128,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">
|
||||||
@@ -154,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
|
||||||
@@ -178,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,
|
||||||
@@ -190,25 +203,25 @@ 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 : MalioSelect ne gere pas fiablement des options a valeur string (cf.
|
|
||||||
// note Lesstime CLAUDE.md). On utilise un `<select>` natif stylise comme les
|
|
||||||
// inputs dates pour garder un look coherent. '' = "toutes les actions".
|
|
||||||
const actionValue = ref<string>('')
|
|
||||||
const actionOptions = [
|
const actionOptions = [
|
||||||
|
{ value: '', label: t('audit.filters.all_actions') },
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
{ value: 'update', label: t('audit.action.update') },
|
{ value: 'update', label: t('audit.action.update') },
|
||||||
{ value: 'delete', label: t('audit.action.delete') },
|
{ value: 'delete', label: t('audit.action.delete') },
|
||||||
@@ -261,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,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,
|
||||||
})
|
})
|
||||||
@@ -302,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) {
|
||||||
@@ -317,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,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 select action natif -> 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>
|
||||||
|
|||||||
@@ -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:plus"
|
@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,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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
label="Se connecter"
|
label="Se connecter"
|
||||||
button-class="w-full"
|
button-class="w-full"
|
||||||
|
type="submit"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
/>
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async function onChange(site: { id: string; name: string; color: string }): Prom
|
|||||||
// intentionnellement supprimee pour garantir qu'un clic sur le tile
|
// intentionnellement supprimee pour garantir qu'un clic sur le tile
|
||||||
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
|
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
|
||||||
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
|
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
|
||||||
// cle `coltura:site-switch` pour mettre a jour les onglets inactifs
|
// cle `starseed:site-switch` pour mettre a jour les onglets inactifs
|
||||||
// sans clic via auth.fetchUser() / auth.refreshUser().
|
// sans clic via auth.fetchUser() / auth.refreshUser().
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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:plus"
|
@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"
|
||||||
|
|||||||
Generated
+826
-34
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "coltura-frontend",
|
"name": "starseed-frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.4.2",
|
"@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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config Playwright pour les tests E2E de Coltura.
|
* Config Playwright pour les tests E2E de Starseed.
|
||||||
*
|
*
|
||||||
* Pre-requis avant de lancer :
|
* Pre-requis avant de lancer :
|
||||||
* 1. Les containers Docker tournent (`make start`)
|
* 1. Les containers Docker tournent (`make start`)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,14 +2,23 @@
|
|||||||
* Composable de lecture des modules actifs (source : `/api/modules`).
|
* Composable de lecture des modules actifs (source : `/api/modules`).
|
||||||
*
|
*
|
||||||
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
* State singleton au niveau module : `useSidebar` suit la meme convention.
|
||||||
* Chargement idempotent via le flag `loaded`, reset explicite au logout
|
* Chargement idempotent via le flag `loaded`, reset automatique au logout
|
||||||
* (voir pages/logout.vue).
|
* via `onAuthSessionCleared` (cf. CLAUDE.md : « composables avec state
|
||||||
|
* singleton doivent etre reinitialises au logout »).
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
const activeModuleIds = ref<string[]>([])
|
const activeModuleIds = ref<string[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
function resetModulesState(): void {
|
||||||
|
activeModuleIds.value = []
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onAuthSessionCleared(resetModulesState)
|
||||||
|
|
||||||
export function useModules() {
|
export function useModules() {
|
||||||
async function loadModules() {
|
async function loadModules() {
|
||||||
try {
|
try {
|
||||||
@@ -35,8 +44,7 @@ export function useModules() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetModules() {
|
function resetModules() {
|
||||||
activeModuleIds.value = []
|
resetModulesState()
|
||||||
loaded.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { SidebarSection } from '~/shared/types'
|
import type { SidebarSection } from '~/shared/types'
|
||||||
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
const sections = ref<SidebarSection[]>([])
|
const sections = ref<SidebarSection[]>([])
|
||||||
const disabledRoutes = ref<string[]>([])
|
const disabledRoutes = ref<string[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
function resetSidebarState(): void {
|
||||||
|
sections.value = []
|
||||||
|
disabledRoutes.value = []
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-enregistrement singleton : purge la sidebar sur 401/logout pour
|
||||||
|
// eviter qu'un nouvel utilisateur logue sur le meme onglet voie transitoirement
|
||||||
|
// les items de l'ancienne session (cf. CLAUDE.md : « composables avec state
|
||||||
|
// singleton doivent etre reinitialises au logout »).
|
||||||
|
onAuthSessionCleared(resetSidebarState)
|
||||||
|
|
||||||
export function useSidebar() {
|
export function useSidebar() {
|
||||||
async function loadSidebar() {
|
async function loadSidebar() {
|
||||||
try {
|
try {
|
||||||
@@ -31,9 +44,7 @@ export function useSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetSidebar() {
|
function resetSidebar() {
|
||||||
sections.value = []
|
resetSidebarState()
|
||||||
disabledRoutes.value = []
|
|
||||||
loaded.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {Config} from 'tailwindcss'
|
import type {Config} from 'tailwindcss'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config Tailwind du projet Coltura.
|
* Config Tailwind du projet Starseed.
|
||||||
*
|
*
|
||||||
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
* @nuxtjs/tailwindcss merge automatiquement les configs de chaque layer
|
||||||
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
* Nuxt declare dans `nuxt.config.ts:extends`. Le layer `@malio/layer-ui`
|
||||||
@@ -11,7 +11,7 @@ import type {Config} from 'tailwindcss'
|
|||||||
* success,btn-*,site-blue,site-yellow,site-green}
|
* success,btn-*,site-blue,site-yellow,site-green}
|
||||||
* - fontFamily.sans (Helvetica Neue)
|
* - fontFamily.sans (Helvetica Neue)
|
||||||
*
|
*
|
||||||
* Cette config locale ne redeclare QUE ce qui est specifique a Coltura
|
* Cette config locale ne redeclare QUE ce qui est specifique a Starseed
|
||||||
* ou absent de la config Malio — evite la duplication et les derives.
|
* ou absent de la config Malio — evite la duplication et les derives.
|
||||||
*/
|
*/
|
||||||
export default <Partial<Config>>{
|
export default <Partial<Config>>{
|
||||||
@@ -19,7 +19,7 @@ export default <Partial<Config>>{
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Couleurs applicatives Coltura (hors namespace `m` reserve
|
// Couleurs applicatives Starseed (hors namespace `m` reserve
|
||||||
// au design system Malio partage).
|
// au design system Malio partage).
|
||||||
primary: {
|
primary: {
|
||||||
500: '#222783',
|
500: '#222783',
|
||||||
|
|||||||
@@ -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,8 +1,8 @@
|
|||||||
DOCKER_APP_NAME=coltura
|
DOCKER_APP_NAME=starseed
|
||||||
DOCKER_PHP_VERSION=8.4.6
|
DOCKER_PHP_VERSION=8.4.6
|
||||||
DOCKER_NODE_VERSION=24.12.0
|
DOCKER_NODE_VERSION=24.12.0
|
||||||
APP_USER=www-data
|
APP_USER=www-data
|
||||||
POSTGRES_DB=coltura
|
POSTGRES_DB=starseed
|
||||||
POSTGRES_USER=root
|
POSTGRES_USER=root
|
||||||
POSTGRES_PASSWORD=root
|
POSTGRES_PASSWORD=root
|
||||||
POSTGRES_PORT=5437
|
POSTGRES_PORT=5437
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ APP_ENV=prod
|
|||||||
APP_DEBUG=0
|
APP_DEBUG=0
|
||||||
APP_SECRET=CHANGE_ME_IN_PRODUCTION
|
APP_SECRET=CHANGE_ME_IN_PRODUCTION
|
||||||
|
|
||||||
DATABASE_URL="postgresql://coltura:CHANGE_ME@host.docker.internal:5432/coltura?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://starseed:CHANGE_ME@host.docker.internal:5432/starseed_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
|
||||||
JWT_COOKIE_SECURE=1
|
# HTTP en reseau local => cookie non secure
|
||||||
|
JWT_COOKIE_SECURE=0
|
||||||
JWT_TOKEN_TTL=86400
|
JWT_TOKEN_TTL=86400
|
||||||
JWT_COOKIE_TTL=86400
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
CORS_ALLOW_ORIGIN='^https://coltura\.malio-dev\.fr$'
|
CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$'
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ RUN rm -f /etc/nginx/sites-enabled/default
|
|||||||
|
|
||||||
# Configs
|
# Configs
|
||||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/coltura.conf
|
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/starseed.conf
|
||||||
|
|
||||||
# Backend from stage 1
|
# Backend from stage 1
|
||||||
COPY --from=backend-build /app /var/www/html
|
COPY --from=backend-build /app /var/www/html
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
export COLTURA_IMAGE_TAG="$TAG"
|
export STARSEED_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
echo "==> Deploying coltura:${TAG}..."
|
echo "==> Deploying starseed:${TAG}..."
|
||||||
|
|
||||||
echo "==> Enabling maintenance mode..."
|
echo "==> Enabling maintenance mode..."
|
||||||
touch maintenance.on
|
touch maintenance.on
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
|
image: gitea.malio.fr/malio-dev/starseed:${STARSEED_IMAGE_TAG:-latest}
|
||||||
container_name: coltura-app
|
container_name: starseed-app
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "8086:80"
|
- "8086:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||||
- coltura_logs:/var/www/html/var/log
|
- starseed_logs:/var/www/html/var/log
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
coltura_logs:
|
starseed_logs:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name coltura.malio-dev.fr;
|
server_name starseed.malio-dev.fr;
|
||||||
|
|
||||||
root /var/www/coltura/public;
|
root /var/www/starseed/public;
|
||||||
|
|
||||||
# Maintenance mode
|
# Maintenance mode
|
||||||
if (-f /var/www/coltura/maintenance.on) {
|
if (-f /var/www/starseed/maintenance.on) {
|
||||||
return 503;
|
return 503;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
|||||||
include $(ENV_DEFAULT)
|
include $(ENV_DEFAULT)
|
||||||
-include $(ENV_LOCAL)
|
-include $(ENV_LOCAL)
|
||||||
|
|
||||||
|
# Export du UID/GID host pour que docker compose les voie dans toutes les targets
|
||||||
|
# (sinon le build du Dockerfile dev fail sur `usermod -u ${CURRENT_UID}` quand l'image
|
||||||
|
# n'est pas en cache, ex: apres renommage du compose project).
|
||||||
|
export CURRENT_UID := $(shell id -u)
|
||||||
|
export CURRENT_GID := $(shell id -g)
|
||||||
|
|
||||||
PHP_CONTAINER = php-$(DOCKER_APP_NAME)-fpm
|
PHP_CONTAINER = php-$(DOCKER_APP_NAME)-fpm
|
||||||
SYMFONY_CONSOLE = $(EXEC_PHP) php bin/console
|
SYMFONY_CONSOLE = $(EXEC_PHP) php bin/console
|
||||||
|
|
||||||
@@ -26,7 +32,7 @@ FILES =
|
|||||||
|
|
||||||
# Affiche l'aide — cible par defaut (make ou make help)
|
# Affiche l'aide — cible par defaut (make ou make help)
|
||||||
help:
|
help:
|
||||||
@printf "\n \033[1mColtura — Commandes make\033[0m\n\n"
|
@printf "\n \033[1mStarseed — Commandes make\033[0m\n\n"
|
||||||
@printf " \033[1;33mContainers\033[0m\n"
|
@printf " \033[1;33mContainers\033[0m\n"
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "start" "Demarrer les containers Docker"
|
@printf " \033[36m%-28s\033[0m %s\n" "start" "Demarrer les containers Docker"
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "stop" "Arreter les containers"
|
@printf " \033[36m%-28s\033[0m %s\n" "stop" "Arreter les containers"
|
||||||
@@ -64,6 +70,7 @@ help:
|
|||||||
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
|
@printf " \033[36m%-28s\033[0m %s\n" "install-e2e-deps" "One-time : Chromium + libs systeme (sudo)"
|
||||||
@printf "\n \033[1;33mQualite code\033[0m\n"
|
@printf "\n \033[1;33mQualite code\033[0m\n"
|
||||||
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
|
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-allow-risky" "Fix code style PHP (utilise par le pre-commit)"
|
||||||
|
@printf " \033[36m%-28s\033[0m %s\n" "php-cs-fixer-check" "Dry-run du fixer (CI / verif avant push)"
|
||||||
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
|
@printf "\n Plus de details : \033[4mREADME.md\033[0m, \033[4mCLAUDE.md\033[0m\n\n"
|
||||||
|
|
||||||
env-init:
|
env-init:
|
||||||
@@ -193,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
|
||||||
@@ -252,6 +273,11 @@ php-cs-fixer-allow-risky:
|
|||||||
@echo "Fixing files: $(FILES)"
|
@echo "Fixing files: $(FILES)"
|
||||||
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
|
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes $(FILES)
|
||||||
|
|
||||||
|
# Dry-run du fixer : echec si au moins un fichier n'est pas conforme.
|
||||||
|
# Utilise par la CI (Gitea pull_request) et avant un push manuel.
|
||||||
|
php-cs-fixer-check:
|
||||||
|
$(EXEC_PHP_CS_FIXER) fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff $(FILES)
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use Doctrine\Migrations\AbstractMigration;
|
|||||||
* Module Sites - Ticket 1/4 : brique fondatrice de donnees.
|
* Module Sites - Ticket 1/4 : brique fondatrice de donnees.
|
||||||
*
|
*
|
||||||
* Cree la table `site` qui porte les etablissements physiques de l'instance
|
* Cree la table `site` qui porte les etablissements physiques de l'instance
|
||||||
* Coltura. La table est creee inconditionnellement : meme si SitesModule est
|
* Starseed. La table est creee inconditionnellement : meme si SitesModule est
|
||||||
* desactive dans `config/modules.php`, la structure DB existe (pas de
|
* desactive dans `config/modules.php`, la structure DB existe (pas de
|
||||||
* dependance dure depuis Core, mais pas de coin d'ombre schema non plus).
|
* dependance dure depuis Core, mais pas de coin d'ombre schema non plus).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).'"',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -18,11 +18,11 @@
|
|||||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||||
|
|
||||||
<!-- ###+ symfony/framework-bundle ### -->
|
<!-- ###+ symfony/framework-bundle ### -->
|
||||||
<!-- APP_ENV est force a "test" en <server> ci-dessus : on ne doit PAS
|
<!-- APP_ENV est defini uniquement via <server force="true"> ci-dessus.
|
||||||
re-injecter "dev" ici via <env>, sinon la suite tourne sous
|
Ne PAS re-declarer ici en <env> : une ligne redondante mene
|
||||||
framework.test=false et `test.service_container` n'est pas cable
|
directement au bug ou un dev met "dev" en pensant que <server>
|
||||||
(cf. cc8d5 du fix pre-existant). -->
|
gere tout, puis supprime <server> ensuite et <env> prend le
|
||||||
<env name="APP_ENV" value="test"/>
|
dessus silencieusement (cf. cc8d5 du fix pre-existant). -->
|
||||||
<env name="APP_SECRET" value=""/>
|
<env name="APP_SECRET" value=""/>
|
||||||
<env name="APP_SHARE_DIR" value="var/share"/>
|
<env name="APP_SHARE_DIR" value="var/share"/>
|
||||||
<!-- ###- symfony/framework-bundle ### -->
|
<!-- ###- symfony/framework-bundle ### -->
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,11 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
|
|||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||||
// Note architecture : User.php utilise SiteInterface (Shared) pour les
|
// Note architecture : User.php n'importe plus rien depuis le module Sites.
|
||||||
// type-hints afin de ne pas coupler le module Core au module Sites.
|
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
|
||||||
// La seule reference concrete a Site subsiste dans les metadonnees ORM
|
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
|
||||||
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
|
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
|
||||||
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
|
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
use App\Shared\Domain\Attribute\AuditIgnore;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
@@ -139,10 +138,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* Le groupe `user:list` a ete retire deliberement (securite : evite
|
* Le groupe `user:list` a ete retire deliberement (securite : evite
|
||||||
* de fuiter la liste des sites de chaque user via GET /api/users).
|
* de fuiter la liste des sites de chaque user via GET /api/users).
|
||||||
*
|
*
|
||||||
* @var Collection<int, Site>
|
* @var Collection<int, SiteInterface>
|
||||||
*/
|
*/
|
||||||
#[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
|
#[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
|
||||||
#[ORM\JoinTable(name: 'user_site')]
|
#[ORM\JoinTable(name: 'user_site')]
|
||||||
|
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
|
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||||
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
|
#[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
|
||||||
private Collection $sites;
|
private Collection $sites;
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
|
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
|
||||||
* deliberement (securite : evite de fuiter le site actif via /api/users).
|
* deliberement (securite : evite de fuiter le site actif via /api/users).
|
||||||
*/
|
*/
|
||||||
#[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
|
#[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
|
||||||
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||||
#[Groups(['me:read'])]
|
#[Groups(['me:read'])]
|
||||||
private ?SiteInterface $currentSite = null;
|
private ?SiteInterface $currentSite = null;
|
||||||
@@ -378,7 +379,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, Site>
|
* @return Collection<int, SiteInterface>
|
||||||
*/
|
*/
|
||||||
public function getSites(): Collection
|
public function getSites(): Collection
|
||||||
{
|
{
|
||||||
@@ -392,7 +393,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
* session Doctrine (cf. ticket 2 review point #1).
|
* session Doctrine (cf. ticket 2 review point #1).
|
||||||
*
|
*
|
||||||
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
|
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
|
||||||
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
|
* La classe concrete injectee au runtime est resolue par Doctrine via
|
||||||
|
* `resolve_target_entities` (cf. note architecture en tete de fichier).
|
||||||
*/
|
*/
|
||||||
public function addSite(SiteInterface $site): static
|
public function addSite(SiteInterface $site): static
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -250,7 +250,12 @@ final class UserRbacProcessor implements ProcessorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
|
foreach (self::COLLECTION_MAP as $jsonKey => $accessors) {
|
||||||
if (array_key_exists($jsonKey, $payload)) {
|
// La garde ne doit sauter la restauration que si le payload fournit
|
||||||
|
// un VRAI tableau pour cette cle. Un `null`, un scalaire ou un autre
|
||||||
|
// type doivent etre traites comme "cle absente" : sinon un payload
|
||||||
|
// `{"sites": null}` contourne la restauration et laisse API Platform
|
||||||
|
// vider la collection silencieusement (bypass de la garde).
|
||||||
|
if (array_key_exists($jsonKey, $payload) && is_array($payload[$jsonKey])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Doctrine\DBAL\ArrayParameterType;
|
|||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\DBAL\Query\QueryBuilder;
|
use Doctrine\DBAL\Query\QueryBuilder;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider API Platform pour la resource AuditLog.
|
* Provider API Platform pour la resource AuditLog.
|
||||||
@@ -67,7 +68,11 @@ final readonly class AuditLogProvider implements ProviderInterface
|
|||||||
*/
|
*/
|
||||||
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
private function provideCollection(Operation $operation, array $context): DbalPaginator
|
||||||
{
|
{
|
||||||
$page = $this->pagination->getPage($context);
|
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
|
||||||
|
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
|
||||||
|
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
|
||||||
|
// minimum a 1 cote provider.
|
||||||
|
$page = max(1, $this->pagination->getPage($context));
|
||||||
$itemsPerPage = $this->pagination->getLimit($operation, $context);
|
$itemsPerPage = $this->pagination->getLimit($operation, $context);
|
||||||
$offset = ($page - 1) * $itemsPerPage;
|
$offset = ($page - 1) * $itemsPerPage;
|
||||||
$filters = $this->extractFilters($context['filters'] ?? []);
|
$filters = $this->extractFilters($context['filters'] ?? []);
|
||||||
@@ -128,20 +133,42 @@ final readonly class AuditLogProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['entity_id', 'action', 'performed_by'] as $key) {
|
foreach (['entity_id', 'performed_by'] as $key) {
|
||||||
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
||||||
$filters[$key] = $raw[$key];
|
$filters[$key] = $raw[$key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `action` : whitelist stricte. Un input hors-liste provoquait avant
|
||||||
|
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
|
||||||
|
// le log applicatif a chaque variation ; on rejette en 400 explicite.
|
||||||
|
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
|
||||||
|
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
|
||||||
|
throw new BadRequestHttpException(
|
||||||
|
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$filters['action'] = $raw['action'];
|
||||||
|
}
|
||||||
|
|
||||||
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
|
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
|
||||||
|
// Sans validation, un input malforme remonte jusqu'a Postgres qui
|
||||||
|
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
|
||||||
|
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
|
||||||
|
// On valide en amont et on rejette en 400 explicite.
|
||||||
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
|
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
|
||||||
$range = $raw['performed_at'];
|
$range = $raw['performed_at'];
|
||||||
if (isset($range['after']) && is_string($range['after']) && '' !== $range['after']) {
|
foreach (['after', 'before'] as $bound) {
|
||||||
$filters['performed_at_after'] = $range['after'];
|
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
|
||||||
}
|
continue;
|
||||||
if (isset($range['before']) && is_string($range['before']) && '' !== $range['before']) {
|
}
|
||||||
$filters['performed_at_before'] = $range['before'];
|
if (false === strtotime($range[$bound])) {
|
||||||
|
throw new BadRequestHttpException(sprintf(
|
||||||
|
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
|
||||||
|
$bound,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
$filters['performed_at_'.$bound] = $range[$bound];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
|
|||||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||||
use App\Module\Core\Domain\Security\SystemRoles;
|
use App\Module\Core\Domain\Security\SystemRoles;
|
||||||
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
|
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
@@ -50,7 +50,7 @@ final class SeedE2ECommand extends Command
|
|||||||
private readonly UserRepositoryInterface $userRepository,
|
private readonly UserRepositoryInterface $userRepository,
|
||||||
private readonly RoleRepositoryInterface $roleRepository,
|
private readonly RoleRepositoryInterface $roleRepository,
|
||||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
private readonly PermissionRepositoryInterface $permissionRepository,
|
||||||
private readonly SiteRepositoryInterface $siteRepository,
|
private readonly SiteProviderInterface $siteProvider,
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -60,6 +60,17 @@ final class SeedE2ECommand extends Command
|
|||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
// Garde-fou : cette commande cree un compte admin avec un mot de passe
|
||||||
|
// hardcode. Elle ne doit JAMAIS tourner hors dev/test, meme si le
|
||||||
|
// fichier se retrouve embarque dans une image prod par accident (le
|
||||||
|
// .dockerignore a la racine est la premiere ligne de defense).
|
||||||
|
$env = $_SERVER['APP_ENV'] ?? 'prod';
|
||||||
|
if (!in_array($env, ['dev', 'test'], true)) {
|
||||||
|
$io->error(sprintf('app:seed-e2e est refuse en environnement "%s". Autorise uniquement en dev/test.', $env));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
$userRole = $this->roleRepository->findByCode(SystemRoles::USER_CODE);
|
$userRole = $this->roleRepository->findByCode(SystemRoles::USER_CODE);
|
||||||
|
|
||||||
if (null === $userRole) {
|
if (null === $userRole) {
|
||||||
@@ -71,7 +82,7 @@ final class SeedE2ECommand extends Command
|
|||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultSite = $this->siteRepository->findByName(self::DEFAULT_SITE_NAME);
|
$defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
|
||||||
|
|
||||||
// Pas de fail fatal si le site manque : les tests sidebar/login
|
// Pas de fail fatal si le site manque : les tests sidebar/login
|
||||||
// n'en dependent pas. Les tests sites-scope-bypass (a venir) le feront.
|
// n'en dependent pas. Les tests sites-scope-bypass (a venir) le feront.
|
||||||
@@ -173,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',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use App\Module\Core\Domain\Entity\Role;
|
|||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||||
use App\Module\Core\Domain\Security\SystemRoles;
|
use App\Module\Core\Domain\Security\SystemRoles;
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
|
||||||
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
|
|
||||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
use Doctrine\Persistence\ObjectManager;
|
use Doctrine\Persistence\ObjectManager;
|
||||||
@@ -39,7 +39,7 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
private readonly RoleRepositoryInterface $roleRepository,
|
private readonly RoleRepositoryInterface $roleRepository,
|
||||||
private readonly SiteRepositoryInterface $siteRepository,
|
private readonly SiteProviderInterface $siteProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,9 +135,9 @@ class AppFixtures extends Fixture implements DependentFixtureInterface
|
|||||||
return $role;
|
return $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function requireSite(string $name): Site
|
private function requireSite(string $name): SiteInterface
|
||||||
{
|
{
|
||||||
$site = $this->siteRepository->findByName($name);
|
$site = $this->siteProvider->findByName($name);
|
||||||
|
|
||||||
if (null === $site) {
|
if (null === $site) {
|
||||||
throw new RuntimeException(sprintf(
|
throw new RuntimeException(sprintf(
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ final class AuditListener
|
|||||||
$em = $args->getObjectManager();
|
$em = $args->getObjectManager();
|
||||||
$uow = $em->getUnitOfWork();
|
$uow = $em->getUnitOfWork();
|
||||||
|
|
||||||
|
// Reset defensif en debut de cycle : si un flush precedent a leve une
|
||||||
|
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
|
||||||
|
// rempli avec des changements jamais committes. Sans ce reset, un
|
||||||
|
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
|
||||||
|
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
|
||||||
|
// ce reset ne le fragilise donc pas.
|
||||||
|
$this->pendingLogs = [];
|
||||||
|
|
||||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||||
$this->capturePendingLog($entity, $em, $uow, 'create');
|
$this->capturePendingLog($entity, $em, $uow, 'create');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
|
* Site physique (usine / etablissement) appartenant a l'instance Starseed.
|
||||||
*
|
*
|
||||||
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
|
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
|
||||||
* permettre des recherches/tris fins ulterieurs et eviter les divergences
|
* permettre des recherches/tris fins ulterieurs et eviter les divergences
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace App\Module\Sites\Domain\Repository;
|
namespace App\Module\Sites\Domain\Repository;
|
||||||
|
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||||
|
|
||||||
interface SiteRepositoryInterface
|
interface SiteRepositoryInterface extends SiteProviderInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?Site;
|
public function findById(int $id): ?Site;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SitesFixtures extends Fixture
|
|||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
// Chatellerault : bleu Coltura.
|
// Chatellerault : bleu Starseed.
|
||||||
$this->ensureSite(
|
$this->ensureSite(
|
||||||
$manager,
|
$manager,
|
||||||
name: 'Chatellerault',
|
name: 'Chatellerault',
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat minimal pour acceder a un site depuis un module qui n'est pas Sites.
|
||||||
|
*
|
||||||
|
* Permet a du code Core/Shared (commandes de seed, fixtures, etc.) de
|
||||||
|
* recuperer un Site par son nom sans importer directement depuis le module
|
||||||
|
* Sites — ce qui violerait la regle "jamais d'import direct entre modules"
|
||||||
|
* (cf. CLAUDE.md section "Regles d'architecture").
|
||||||
|
*
|
||||||
|
* Implementation concrete : App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository
|
||||||
|
* (via SiteRepositoryInterface qui etend ce contrat).
|
||||||
|
*/
|
||||||
|
interface SiteProviderInterface
|
||||||
|
{
|
||||||
|
public function findByName(string $name): ?SiteInterface;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user