docs(catalog) : add Timestampable + Blamable Shared pattern (v1.2)
This commit is contained in:
@@ -8,13 +8,13 @@ pipeline: ui+back # tickets back ET front (UI admin standard,
|
||||
owner: Matthieu
|
||||
backup: Tristan
|
||||
date: 2026-05-26
|
||||
version: 1.1 # 1.0 = draft initial ; 1.1 = aligné sur l'archi Starseed réelle
|
||||
version: 1.2 # 1.0 draft ; 1.1 aligné Starseed ; 1.2 + pattern Timestampable/Blamable Shared
|
||||
|
||||
# === LIENS ===
|
||||
lien_spec_front: ./spec-front.md
|
||||
figma: null # pas de maquette — UI admin standard (datatable + drawer)
|
||||
dependances: [] # M0 = premier module, aucune dépendance
|
||||
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, RG-1.14]
|
||||
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, RG-1.14, RG-1.15, RG-1.16, RG-1.17]
|
||||
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||
|
||||
# === VALIDATION CLIENT ===
|
||||
@@ -118,16 +118,33 @@ Aucun pattern soft delete existant dans Starseed (vérifié, aucune entité ne p
|
||||
|
||||
Index unique partiel sur `(LOWER(name), category_type_id) WHERE deleted_at IS NULL`. Permet de recréer une catégorie avec le même `(name, type)` après suppression logique. Postgres supporte nativement (`CREATE UNIQUE INDEX ... WHERE`). Pattern propre, pas besoin de validator applicatif maison côté unicité — la contrainte SQL fait le job.
|
||||
|
||||
### 2.5 Audit : `#[Auditable]` Starseed standard
|
||||
### 2.5 Audit & traces temporelles — deux niveaux complémentaires
|
||||
|
||||
Deux mécanismes **indépendants** cohabitent :
|
||||
|
||||
**(a) Audit complet — table `audit_log` via `#[Auditable]`**
|
||||
|
||||
L'entité `Category` porte `#[Auditable]` (`App\Shared\Domain\Attribute\Auditable`). Le `AuditListener` (`src/Module/Core/Infrastructure/Doctrine/AuditListener.php`) intercepte `onFlush` + `postFlush` et écrit une ligne dans `audit_log` à chaque création / modification / suppression logique (le soft delete = un UPDATE pour Doctrine, donc tracé comme un UPDATE).
|
||||
|
||||
**Important** : `#[Auditable]` **ne crée PAS** automatiquement les colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur l'entité. Il trace les changements dans une table séparée `audit_log` (qui contient `performed_by` + `performed_at` + diff JSON). Conséquence pour le M0 :
|
||||
`audit_log` contient `performed_by`, `performed_at`, `changes` JSONB complet, `ip_address`, `request_id`. C'est l'historique **détaillé et auditable** d'une entité, consultable via `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint fourni par Core).
|
||||
|
||||
- **Pas de colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur `category`.** Le « qui a créé / modifié / quand » est lisible via l'endpoint d'historique `GET /api/audit-log?entityType=Category&entityId={id}` (déjà fourni par Core).
|
||||
- C'est cohérent avec les autres entités Starseed (User n'a qu'un `createdAt` géré manuellement dans le constructor, pas de `updatedAt` ; Role n'a rien).
|
||||
**(b) Traces locales sur l'entité — `created_at` / `updated_at` / `created_by` / `updated_by`**
|
||||
|
||||
Si plus tard un besoin de tri par `updated_at` côté front se fait sentir, on pourra rajouter la colonne. Au M0, on ne devine pas.
|
||||
Pour l'affichage courant (datatable, drawer de détail, tri par date) sans avoir à requêter `audit_log` à chaque rendu, on ajoute 4 colonnes sur l'entité. Le pattern est **global et automatique** (cf. § 2.8) : un `TimestampableBlamableSubscriber` dans `Shared/Infrastructure/Doctrine/` les remplit au `prePersist` / `preUpdate` pour toute entité implémentant `TimestampableInterface` et/ou `BlamableInterface`.
|
||||
|
||||
**Pourquoi les deux** :
|
||||
|
||||
| Cas d'usage | Mécanisme à utiliser |
|
||||
|---|---|
|
||||
| Afficher « créée le 22/05/2026 » dans le datatable | Colonne locale `created_at` (cheap, pas de jointure) |
|
||||
| Trier la liste par « dernière modif » | Colonne locale `updated_at` (index possible) |
|
||||
| Afficher « créée par Alice » dans le drawer | Colonne locale `created_by` (FK directe, JOIN simple) |
|
||||
| Historique détaillé « qui a changé quoi à quel moment » | `audit_log` (diff JSONB complet, traçabilité forensique) |
|
||||
| Recalculer un état à un instant T | `audit_log` (seul à avoir les diffs) |
|
||||
|
||||
Les colonnes locales sont **une vue dénormalisée** des dernières actions auditées. `audit_log` reste la source de vérité historique.
|
||||
|
||||
**Pas de duplication problématique** : `created_at` + `updated_at` sont peu coûteux (2 timestamps), et `created_by` + `updated_by` sont 2 FK vers `user`. Le `audit_log`, lui, garde tout l'historique (n lignes pour n modifications). Les deux ont des rôles distincts.
|
||||
|
||||
### 2.6 Pagination & tri
|
||||
|
||||
@@ -144,6 +161,290 @@ Pour M0 :
|
||||
|
||||
Les deux permissions seront attachées **uniquement au rôle métier `Admin`** dans `AppFixtures` et `SeedE2ECommand`. Bureau / Compta / Commerciale / Usine n'en reçoivent aucune → 403 systématique.
|
||||
|
||||
### 2.8 Pattern Timestampable + Blamable Shared (nouveau, transverse)
|
||||
|
||||
**Objectif** : poser une fois pour toutes le pattern « 4 colonnes `created_at/updated_at/created_by/updated_by` automatisées sur toute entité métier » dans `Shared/`. Réutilisable par Catalog (M0), puis par les futurs modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) et au-delà. Décidé en V1.2 de la spec (auparavant : pas de colonnes locales, tout dans `audit_log` — choix révisé pour les besoins d'affichage courant).
|
||||
|
||||
#### Composants
|
||||
|
||||
```
|
||||
src/Shared/
|
||||
├── Domain/
|
||||
│ ├── Contract/
|
||||
│ │ ├── TimestampableInterface.php (nouveau — contrat lu par le Subscriber)
|
||||
│ │ └── BlamableInterface.php (nouveau)
|
||||
│ └── Trait/
|
||||
│ └── TimestampableBlamableTrait.php (nouveau — colonnes + getters/setters)
|
||||
└── Infrastructure/
|
||||
└── Doctrine/
|
||||
└── TimestampableBlamableSubscriber.php (nouveau — remplit prePersist/preUpdate)
|
||||
```
|
||||
|
||||
**Stratégie : Trait + Interfaces + Subscriber** :
|
||||
|
||||
- Le **Trait** porte les 4 propriétés Doctrine + leurs getters/setters → **le dev ajoute juste `use TimestampableBlamableTrait;` dans son entité** et tout est là.
|
||||
- Les **Interfaces** servent de marqueur typé pour le Subscriber (qui fait `instanceof TimestampableInterface`).
|
||||
- Le **Subscriber** est un listener Doctrine global qui remplit automatiquement les valeurs.
|
||||
|
||||
**Conséquence pour le dev d'une nouvelle entité métier** : 3 lignes à ajouter à l'entité (1 `use` du Trait, 2 `implements` d'interfaces). Aucune autre démarche. La migration ajoute les 4 colonnes en SQL.
|
||||
|
||||
#### `TimestampableInterface`
|
||||
|
||||
```php
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
interface TimestampableInterface
|
||||
{
|
||||
public function getCreatedAt(): ?DateTimeImmutable;
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): void;
|
||||
|
||||
public function getUpdatedAt(): ?DateTimeImmutable;
|
||||
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
|
||||
}
|
||||
```
|
||||
|
||||
#### `BlamableInterface`
|
||||
|
||||
```php
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
interface BlamableInterface
|
||||
{
|
||||
public function getCreatedBy(): ?UserInterface;
|
||||
public function setCreatedBy(?UserInterface $user): void;
|
||||
|
||||
public function getUpdatedBy(): ?UserInterface;
|
||||
public function setUpdatedBy(?UserInterface $user): void;
|
||||
}
|
||||
```
|
||||
|
||||
> Le type-hint utilise `Symfony\Component\Security\Core\User\UserInterface` (déjà implémentée par `App\Module\Core\Domain\Entity\User`) pour éviter de coupler `Shared` à `Module/Core`. Résolution Doctrine via `resolve_target_entities` (cf. § 2.1).
|
||||
|
||||
#### `TimestampableBlamableTrait`
|
||||
|
||||
```php
|
||||
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'entité, +
|
||||
* `implements TimestampableInterface, BlamableInterface`. Le
|
||||
* TimestampableBlamableSubscriber remplit les colonnes automatiquement.
|
||||
*
|
||||
* Les Groups Serializer utilisent une convention `default:read` agrégée :
|
||||
* pour exposer les 4 colonnes dans une réponse API d'une entité X, ajouter
|
||||
* `default:read` au normalizationContext aux côtés du groupe `x:read`
|
||||
* (ou exposer directement via le groupe x:read si on préfère pas la convention).
|
||||
*/
|
||||
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; }
|
||||
}
|
||||
```
|
||||
|
||||
> Sur Category, le `category:read` doit inclure `default:read` côté config Serializer (ou les groupes du trait doivent être ajustés à `category:read`). À trancher au moment du dev — convention `default:read` proposée pour réutilisation cross-modules.
|
||||
|
||||
#### `TimestampableBlamableSubscriber`
|
||||
|
||||
Listener Doctrine `prePersist` + `preUpdate`. Pattern aligné sur `AuditListener` (cf. `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`).
|
||||
|
||||
```php
|
||||
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;
|
||||
|
||||
#[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);
|
||||
}
|
||||
// Si pas d'utilisateur authentifié (CLI, cron, migration) : on laisse
|
||||
// les FK Blamable à null. L'affichage front gère "Système" pour null.
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Décision sur la nullability
|
||||
|
||||
| Champ | Nullability BDD | Justification |
|
||||
|---|---|---|
|
||||
| `created_at` | **NOT NULL** | Le subscriber le remplit systématiquement au `prePersist`. Jamais vide. |
|
||||
| `updated_at` | **NOT NULL** | Idem. Au `prePersist` égal à `created_at`. |
|
||||
| `created_by` | **nullable** (`ON DELETE SET NULL`) | Permet les créations hors contexte HTTP (cron, console, migration). Permet la suppression d'un user sans bloquer les entités existantes. |
|
||||
| `updated_by` | **nullable** (`ON DELETE SET NULL`) | Idem. |
|
||||
|
||||
Affichage front : si `created_by IS NULL` → libellé `« Système »` côté Nuxt.
|
||||
|
||||
#### Application à `Category` au M0
|
||||
|
||||
```php
|
||||
class Category implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait; // ← apporte les 4 colonnes + getters/setters
|
||||
// ... reste métier (id, name, categoryType, deletedAt)
|
||||
}
|
||||
```
|
||||
|
||||
Aucune ligne supplémentaire côté entité — le Trait porte les props, le Subscriber les remplit.
|
||||
|
||||
#### Propagation aux futures entités — règle dure
|
||||
|
||||
Toute nouvelle entité Doctrine sous `src/Module/*/Domain/Entity/` qui représente un **agrégat métier** (et non un référentiel statique purement infra) **doit** :
|
||||
|
||||
1. `use TimestampableBlamableTrait;`
|
||||
2. `implements TimestampableInterface, BlamableInterface`
|
||||
3. Inclure les 4 colonnes dans sa migration Doctrine
|
||||
|
||||
À documenter dans `.claude/rules/backend.md` après merge du M0 (cf. § 2.8.bis ci-dessous pour les garde-fous techniques).
|
||||
|
||||
#### 2.8.bis Garde-fous pour ne pas oublier (proposition)
|
||||
|
||||
Plusieurs niveaux possibles, du plus léger au plus contraignant :
|
||||
|
||||
| Niveau | Mécanisme | Détecte quoi |
|
||||
|---|---|---|
|
||||
| **L1 — Convention** | Règle ajoutée à `.claude/rules/backend.md` | Rappel au dev. Aucun blocage technique. |
|
||||
| **L2 — PHPStan custom rule** | `tests/PHPStan/EntityMustBeTimestampableBlamableRule.php` qui vérifie que toute classe annotée `#[ORM\Entity]` sous `src/Module/*/Domain/Entity/` implémente les 2 interfaces | Catch en lint avant commit. |
|
||||
| **L3 — Test PHPUnit** | `tests/Architecture/EntitiesAreTimestampableBlamableTest.php` qui scanne `src/Module/*/Domain/Entity/`, instancie chaque entité et vérifie `instanceof` | Catch en CI. Bonus : peut whitelister explicitement les entités exclues (référentiels statiques type `CategoryType`). |
|
||||
| **L4 — Hook pre-commit** | Extension du `pre-commit` actuel qui boucle sur les fichiers PHP staged et grep | Catch avant push. Plus invasif. |
|
||||
|
||||
**Reco** : **L1 + L3** au M0. L1 documente, L3 garantit (CI rouge si oubli). L2 et L4 sont des bonus si tu vois la dette s'accumuler.
|
||||
|
||||
Exemple de test L3 :
|
||||
|
||||
```php
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
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;
|
||||
|
||||
final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
{
|
||||
/** Entités exclues : référentiels statiques sans intérêt à auditer */
|
||||
private const EXCLUDED = [
|
||||
\App\Module\Catalog\Domain\Entity\CategoryType::class,
|
||||
// ajouter ici les futurs référentiels stables
|
||||
];
|
||||
|
||||
public function testAllBusinessEntitiesImplementBothInterfaces(): void
|
||||
{
|
||||
$finder = (new Finder())
|
||||
->files()
|
||||
->in(__DIR__ . '/../../src/Module')
|
||||
->path('Domain/Entity')
|
||||
->name('*.php');
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$fqcn = $this->extractFqcn($file->getRealPath());
|
||||
if (!$fqcn || in_array($fqcn, self::EXCLUDED, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($fqcn);
|
||||
if ($reflection->isAbstract() || $reflection->getAttributes(Entity::class) === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertTrue(
|
||||
$reflection->implementsInterface(TimestampableInterface::class)
|
||||
&& $reflection->implementsInterface(BlamableInterface::class),
|
||||
sprintf(
|
||||
'L\'entité %s doit implémenter TimestampableInterface ET BlamableInterface (utiliser TimestampableBlamableTrait). '
|
||||
. 'Si c\'est un référentiel statique justifié, ajouter dans EntitiesAreTimestampableBlamableTest::EXCLUDED.',
|
||||
$fqcn,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractFqcn(string $path): ?string { /* extrait namespace+class du fichier */ }
|
||||
}
|
||||
```
|
||||
|
||||
Coût d'écriture : 1h. Coût en CI : ~50ms. Bénéfice : 0 oubli possible. À écrire dans le ticket 0.0.
|
||||
|
||||
#### Tests Subscriber
|
||||
|
||||
Tests unitaires du Subscriber : créer une entité de test minimale (fixture interne aux tests) qui `use` le Trait + implements les interfaces, vérifier que `prePersist` + `preUpdate` remplissent les 4 colonnes. À écrire dans le ticket 0.0.
|
||||
|
||||
## 3. Modèle de données
|
||||
|
||||
### 3.1 Diagramme
|
||||
@@ -151,11 +452,16 @@ Les deux permissions seront attachées **uniquement au rôle métier `Admin`** d
|
||||
```mermaid
|
||||
erDiagram
|
||||
CATEGORY_TYPE ||--o{ CATEGORY : "classe"
|
||||
USER ||--o{ CATEGORY : "created_by / updated_by"
|
||||
CATEGORY {
|
||||
int id PK "INT IDENTITY"
|
||||
string name "VARCHAR(120) NOT NULL"
|
||||
int category_type_id FK "INT NOT NULL"
|
||||
timestamp deleted_at "nullable (soft delete)"
|
||||
timestamp created_at "NOT NULL — Subscriber"
|
||||
timestamp updated_at "NOT NULL — Subscriber"
|
||||
int created_by FK "nullable, ON DELETE SET NULL"
|
||||
int updated_by FK "nullable, ON DELETE SET NULL"
|
||||
}
|
||||
CATEGORY_TYPE {
|
||||
int id PK "INT IDENTITY"
|
||||
@@ -216,9 +522,17 @@ final class VersionYYYYMMDDHHMMSS extends AbstractMigration
|
||||
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
|
||||
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);
|
||||
|
||||
@@ -231,6 +545,8 @@ final class VersionYYYYMMDDHHMMSS extends AbstractMigration
|
||||
|
||||
$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
|
||||
@@ -262,6 +578,9 @@ use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\CategoryProces
|
||||
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;
|
||||
@@ -300,7 +619,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: DoctrineCategoryRepository::class)]
|
||||
#[ORM\Table(name: 'category')]
|
||||
#[Auditable]
|
||||
class Category
|
||||
class Category implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -328,7 +647,13 @@ class Category
|
||||
#[Groups(['category:read'])]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
// getters / setters classiques (générés par PhpStorm) — omis ici.
|
||||
// === 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. Aucun code à ajouter ici.
|
||||
use \App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
|
||||
// getters / setters métier (name, categoryType, deletedAt) classiques — omis ici.
|
||||
}
|
||||
```
|
||||
|
||||
@@ -537,7 +862,7 @@ Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche **les 3 sources en
|
||||
|
||||
## 6. Audit & dates
|
||||
|
||||
### 6.1 Audit automatique via `#[Auditable]`
|
||||
### 6.1 Audit complet via `#[Auditable]`
|
||||
|
||||
`Category` porte `#[Auditable]` (`App\Shared\Domain\Attribute\Auditable`). Le `AuditListener` du module Core (`src/Module/Core/Infrastructure/Doctrine/AuditListener.php`) :
|
||||
|
||||
@@ -545,15 +870,16 @@ Règle ABSOLUE Starseed n°8 — toute évolution RBAC touche **les 3 sources en
|
||||
- Intercepte `postFlush` : écrit une ligne dans la table `audit_log` via DBAL (connexion dédiée pour éviter la récursion).
|
||||
- Trace `performed_by` (le user courant), `performed_at`, `changes` (diff JSONB), `request_id`, `ip_address`.
|
||||
|
||||
**Aucun code custom à écrire côté M0.** Il suffit que `Category` porte `#[Auditable]`. Le soft delete (UPDATE `deleted_at`) est tracé comme un UPDATE normal.
|
||||
**Aucun code custom à écrire côté M0** côté Auditable. Il suffit que `Category` porte `#[Auditable]`. Le soft delete (UPDATE `deleted_at`) est tracé comme un UPDATE normal.
|
||||
|
||||
### 6.2 Pas de colonnes `created_by` / `updated_by` / `created_at` / `updated_at` sur `category`
|
||||
### 6.2 Timestampable + Blamable via Trait + Subscriber Shared
|
||||
|
||||
Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajoute PAS ces colonnes**. Il écrit dans `audit_log` séparément. Cohérent avec les autres entités Starseed (`User` n'a qu'un `createdAt` géré manuellement dans le constructor, pas de `updatedAt` ; `Role` n'a rien).
|
||||
Les 4 colonnes `created_at`, `updated_at`, `created_by`, `updated_by` sont portées par le **Trait `TimestampableBlamableTrait`** (cf. § 2.8) et remplies automatiquement par le **`TimestampableBlamableSubscriber`** au `prePersist` / `preUpdate`.
|
||||
|
||||
→ Pour répondre à « qui a créé / modifié / quand », interroger `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint déjà fourni par `Core`).
|
||||
→ Pour répondre à « qui a créé / quand » dans le datatable, exposer les 4 colonnes via le groupe Serializer (cf. § 3.3). Pas de jointure custom nécessaire.
|
||||
→ Pour répondre à « tout l'historique des modifs », interroger `GET /api/audit-log?entityType=Category&entityId={id}` (endpoint déjà fourni par Core).
|
||||
|
||||
→ Si plus tard un besoin de tri front par date de création se fait sentir, on rajoutera la colonne. **Au M0, on ne devine pas.**
|
||||
Les deux mécanismes sont indépendants : on peut désactiver `#[Auditable]` (par ex. pour une entité à très haute fréquence d'écriture) sans perdre Timestampable/Blamable, et inversement.
|
||||
|
||||
## 7. Règles de gestion (RG)
|
||||
|
||||
@@ -595,6 +921,12 @@ Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajout
|
||||
- **RG-1.13** : Le champ `deletedAt` n'est **jamais** modifiable via PATCH (groupe `category:write` ne le contient pas). Seul le DELETE peut le mettre à jour. Restaurer une catégorie supprimée n'est pas un cas d'usage au M0 (HP-3).
|
||||
- **RG-1.14** : *(à activer post-M0, désactivée à la livraison initiale)* Quand les modules Tiers (M-Clients, M-Fournisseurs, M-Prestas) auront ajouté une FK `category_id` nullable, un DELETE sur une catégorie référencée par au moins un tiers → **409 Conflict** avec message `"Impossible de supprimer : N tier(s) référencent cette catégorie."`. **Au M0, cette règle est documentée mais non implémentée** (rien à référencer, donc rien à empêcher).
|
||||
|
||||
### Timestampable + Blamable
|
||||
|
||||
- **RG-1.15** : Au POST (`prePersist`), le `TimestampableBlamableSubscriber` remplit `createdAt = updatedAt = now()` et `createdBy = updatedBy = user courant` (ou `null` si CLI / cron / migration sans contexte HTTP). Le client ne peut pas écrire ces 4 champs (groupe `category:write` ne les contient pas).
|
||||
- **RG-1.16** : Au PATCH (`preUpdate`), seuls `updatedAt = now()` et `updatedBy = user courant` sont modifiés. `createdAt` et `createdBy` restent **figés à leur valeur initiale**. Même si le PATCH est un soft-delete (passage de `deletedAt` à `now()` via le `CategoryProcessor`), le `updatedAt` / `updatedBy` sont aussi mis à jour (puisque c'est un UPDATE Doctrine).
|
||||
- **RG-1.17** : Toute entité métier nouvellement créée sous `src/Module/*/Domain/Entity/` doit utiliser le `TimestampableBlamableTrait` et implémenter `TimestampableInterface` + `BlamableInterface` (cf. § 2.8 et § 2.8.bis pour les garde-fous techniques). Les référentiels statiques (ex: `CategoryType`) en sont exemptés, **mais doivent être explicitement whitelistés** dans `EntitiesAreTimestampableBlamableTest::EXCLUDED`.
|
||||
|
||||
## 8. Tests à automatiser
|
||||
|
||||
### 8.1 Cas à couvrir (back — PHPUnit)
|
||||
@@ -615,7 +947,12 @@ Contrairement à ce que la V0 brute pourrait suggérer, **`#[Auditable]` n'ajout
|
||||
- [ ] **RG-1.12** : DELETE → 204, ligne toujours présente en BDD avec `deleted_at IS NOT NULL`.
|
||||
- [ ] **RG-1.13** : PATCH avec body `{"deletedAt": null}` ou autre tentative d'écriture → champ ignoré (groupe write l'exclut).
|
||||
- [ ] **Audit** : POST + PATCH + DELETE → un `audit_log` est créé à chaque fois, avec `entity_type='Category'`, `entity_id={id}`, `performed_by={user.id}`, `action` correct, `changes` JSONB correct.
|
||||
- [ ] **Migration** : `make db-reset` → schéma à jour. Vérifier en Postgres (`\d category`) que `uq_category_name_type_active` apparaît comme index partiel.
|
||||
- [ ] **RG-1.15** : POST avec admin → `created_at = updated_at = now()`, `created_by = updated_by = admin.id`.
|
||||
- [ ] **RG-1.15** : POST via console (sans contexte HTTP, mockable via test) → `created_at/updated_at` remplis ; `created_by/updated_by = null`.
|
||||
- [ ] **RG-1.16** : PATCH par bob → `updated_at` change, `updated_by = bob.id`, `created_at` et `created_by` inchangés.
|
||||
- [ ] **RG-1.16** : DELETE → `deleted_at` rempli ET `updated_at` / `updated_by` mis à jour (puisque c'est un UPDATE Doctrine).
|
||||
- [ ] **RG-1.17** : Test architecture `EntitiesAreTimestampableBlamableTest` passe (cf. § 2.8.bis L3). Whitelist `CategoryType` documentée.
|
||||
- [ ] **Migration** : `make db-reset` → schéma à jour. Vérifier en Postgres (`\d category`) que `uq_category_name_type_active` apparaît comme index partiel et que les 4 colonnes timestampable/blamable sont présentes avec leurs FK / index.
|
||||
|
||||
### 8.2 Cas à couvrir (front — Vitest)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user