# Ticket #01 — 1/4 — Brique fondatrice du module Sites (Backend)

## 1. Objectif

Ce ticket livre la couche de donnees du module optionnel Sites. Il cree le bounded context, declare le module a Symfony, enregistre ses permissions RBAC, installe la table `site` en base et seed trois etablissements de demonstration utilises par les tickets suivants.

Le resultat attendu est un socle de persistance activable par tenant via `config/modules.php`, sans UI, sans API publique, sans couplage au module Core, et sur lequel les tickets 2/3/4 pourront greffer : rattachement utilisateurs, selecteur de site dans la navbar, administration CRUD.

## 2. Périmètre

### IN

- 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'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/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.
- 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/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/Starseed/config/services.yaml`.
- Ajouter deux suites de tests PHPUnit :
  - `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.

### 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 `#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).
- Gestion des soft-deletes sur `Site` : non introduite dans ce ticket.
- Rattachement historique ou audit trail des modifications : hors scope.

## 3. Fichiers à créer

### Domaine — Entité

- `/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

- `/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

- `/home/m-tristan/workspace/Starseed/src/Module/Sites/Infrastructure/Doctrine/DoctrineSiteRepository.php` : implementation Doctrine de `SiteRepositoryInterface` basee sur `ServiceEntityRepository`.

### Infrastructure — Migration

- `/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

- `/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

- `/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

- `/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/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

- `/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/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/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

Comme pour le ticket RBAC (ticket-343), le schema est decrit par les attributs Doctrine plutot que par le SQL brut. Le fichier de migration contient le SQL final (section 6).

### Conventions respectées

- `declare(strict_types=1)` en tete de tous les fichiers PHP.
- Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`).
- PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (`postalCode` → `postal_code`, `fullAddress` → `full_address`, `createdAt` → `created_at`, `updatedAt` → `updated_at`).
- Le nom de table `site` n'est pas un mot reserve PostgreSQL : pas de backtick necessaire.

### Entité `Site`

```php
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
#[ORM\Table(name: 'site')]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
class Site
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: 'Le nom du site est requis.')]
    #[Assert\Length(max: 100, ...)]
    private string $name;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: 'La ville du site est requise.')]
    #[Assert\Length(max: 100, ...)]
    private string $city;

    #[ORM\Column(name: 'postal_code', length: 10)]
    #[Assert\NotBlank(message: 'Le code postal est requis.')]
    #[Assert\Length(max: 10, ...)]
    #[Assert\Regex(pattern: '/^\d{5}$/', message: '...')]
    private string $postalCode;

    #[ORM\Column(length: 7)]
    #[Assert\NotBlank(message: 'La couleur est requise.')]
    #[Assert\Regex(pattern: '/^#[0-9A-Fa-f]{6}$/', message: '...')]
    private string $color;

    #[ORM\Column(name: 'full_address', type: Types::TEXT)]
    #[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
    #[Assert\Length(max: 500, ...)]
    private string $fullAddress;

    #[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
    private DateTimeImmutable $createdAt;

    #[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
    private DateTimeImmutable $updatedAt;
}
```

Contraintes fonctionnelles :
- `name` est unique en base (`uniq_site_name`) et porte egalement la contrainte applicative `UniqueEntity` pour que le validator remonte une violation lisible avant d'atteindre la violation DB.
- `color` est contraint par regex a un code hex strict de 7 caracteres `#RRGGBB`, majuscules ou minuscules. La colonne `VARCHAR(7)` est dimensionnee au plus juste car la regex est exhaustive.
- `postalCode` est contraint a 5 chiffres exacts via regex (format FR). La colonne `VARCHAR(10)` est volontairement plus large que la regex pour laisser marge si le projet etend plus tard la regex a d'autres formats (UK, PT, ...). Choix assume : evite une migration DDL au ticket suivant, cout DB negligeable sur un champ court.
- `fullAddress` est de type `TEXT` (PostgreSQL) pour permettre une adresse multi-ligne, mais borne cote applicatif a 500 caracteres via `Assert\Length(max: 500)` comme garde DoS basique (une adresse FR complete tient largement dans cette enveloppe).
- `createdAt` est seede dans le constructeur et **ne change plus jamais** apres persistance.
- `updatedAt` est seede dans le constructeur a la meme valeur que `createdAt`, puis refresh a chaque update via le callback `#[ORM\PreUpdate]`.

### Mapping Doctrine — `doctrine.yaml`

```yaml
# Mapping inconditionnelle du module Sites : la structure DB existe meme
# si SitesModule::class est retire de config/modules.php. L'activation
# fonctionnelle (ex: exposition des permissions, futurs endpoints API)
# passe exclusivement par config/modules.php.
Sites:
    type: attribute
    is_bundle: false
    dir: '%kernel.project_dir%/src/Module/Sites/Domain/Entity'
    prefix: 'App\Module\Sites\Domain\Entity'
    alias: Sites
```

## 6. Plan de migration Doctrine

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

1. Creer la table `site` avec toutes les colonnes NOT NULL :
   - `id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL`
   - `name VARCHAR(100) NOT NULL`
   - `city VARCHAR(100) NOT NULL`
   - `postal_code VARCHAR(10) NOT NULL`
   - `color VARCHAR(7) NOT NULL`
   - `full_address TEXT NOT NULL`
   - `created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL`
   - `updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL`
   - `PRIMARY KEY (id)`
2. Creer l'index unique `uniq_site_name` sur `site(name)` pour garantir l'invariant metier "un site porte un nom unique" au niveau DB. Le validator applicatif `UniqueEntity` s'appuie dessus en lecture avant qu'une tentative d'insertion concurrente ne remonte la violation DB.

### `down()` — rollback

1. `DROP TABLE site`. Aucune FK n'existe depuis ou vers cette table dans ce ticket ; le rollback est donc trivial et safe.

### Precision timestamp

PostgreSQL `TIMESTAMP(0) WITHOUT TIME ZONE` stocke a la seconde pres. Les DateTimeImmutable PHP portent une precision microseconde mais perdent cette precision au round-trip DB. Les tests unitaires de lifecycle doivent en tenir compte (cf. section 10 — usage de reflection plutot qu'un `sleep`).

## 7. Intégration avec sync-permissions

Le ticket ne modifie pas `SyncPermissionsCommand`. Il exploite l'algorithme existant (cf. ticket-343 section 7) en declarant `SitesModule::permissions()` dans un format strictement conforme au contrat attendu par la commande :

```php
public static function permissions(): array
{
    return [
        ['code' => 'sites.view', 'label' => 'Voir les sites'],
        ['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
    ];
}
```

Regles de validation appliquees par `SyncPermissionsCommand` :
- Chaque entree doit contenir exactement les cles `code` et `label`.
- Le prefixe du code doit correspondre a `SitesModule::ID . '.'`, soit `sites.`.
- Ni `code` ni `label` ne peuvent etre une chaine vide.

Comportement a attendre :
- Apres `php bin/console app:sync-permissions`, les deux lignes `sites.view` et `sites.manage` sont presentes dans la table `permission` avec `module = 'sites'` et `orphan = false`.
- Si `SitesModule::class` est retire de `config/modules.php` et la commande relancee, les deux lignes sont marquees `orphan = true` (non supprimees, pour preserver les assignations). Reactiver le module les remet a `orphan = false`.
- La cle `module` n'est **pas** presente dans le payload : elle est auto-injectee par la commande depuis `SitesModule::ID`.

### Granularité des permissions

`sites.manage` est une permission **composite** couvrant creation, edition et suppression. Ce choix reste simple pour un ticket fondateur, mais le ticket 4 (administration CRUD) devra arbitrer si une granularite plus fine (`sites.create`, `sites.edit`, `sites.delete`) est necessaire pour les besoins UX. Si oui, la migration de permissions se fera naturellement via la commande de sync : ajouter les trois codes dans `permissions()`, retirer `sites.manage` → la sync marque l'ancien orphelin sans casser les roles deja existants.

## 8. Méthodes clés détaillées

### `Site::__construct`

Le constructeur prend les cinq champs metier obligatoires et positionne les deux timestamps a la meme valeur :

```php
public function __construct(
    string $name,
    string $city,
    string $postalCode,
    string $color,
    string $fullAddress,
) {
    $this->name        = $name;
    $this->city        = $city;
    $this->postalCode  = $postalCode;
    $this->color       = $color;
    $this->fullAddress = $fullAddress;
    $now               = new DateTimeImmutable();
    $this->createdAt   = $now;
    $this->updatedAt   = $now;
}
```

Justification :
- Tous les champs sont passes au constructeur pour forcer l'invariant "un Site instancie est toujours complet". L'alternative (setters post-new) autoriserait des etats transitoires invalides.
- `createdAt` et `updatedAt` partagent la meme valeur a l'instanciation, ce qui garantit `updated_at >= created_at` au niveau base. Le premier appel a `onPreUpdate()` fera avancer uniquement `updatedAt`.

### `Site::onPreUpdate`

```php
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
    $this->updatedAt = new DateTimeImmutable();
}
```

Justification :
- Callback Doctrine declenche **uniquement** quand Doctrine detecte au moins un changement sur l'entite en session de persistance. Pas de risque de tick silencieux sur un find pur.
- `createdAt` n'est volontairement jamais touche ici : il est immuable apres persistance.
- Pas de `#[ORM\PrePersist]` : le constructeur gere deja l'initialisation, inutile de dupliquer la logique dans un callback qui pourrait etre appele a vide.

### `SitesFixtures::ensureSite`

```php
private function ensureSite(
    ObjectManager $manager,
    string $name,
    string $city,
    string $postalCode,
    string $color,
    string $fullAddress,
): Site {
    $site = $this->siteRepository->findByName($name);

    if (null === $site) {
        $site = new Site($name, $city, $postalCode, $color, $fullAddress);
        $manager->persist($site);

        return $site;
    }

    $site->setCity($city);
    $site->setPostalCode($postalCode);
    $site->setColor($color);
    $site->setFullAddress($fullAddress);

    return $site;
}
```

Contrat honnete sur l'idempotence (cf. docblock en tete de fixture) :
- **Supporte** : lookup par nom avec purger Doctrine actif (cas nominal de `doctrine:fixtures:load`).
- **Supporte** : lookup par nom hors purger si la fixture est rejouee telle quelle sur une base deja seedee → les autres champs sont re-alignes sur les valeurs de reference.
- **Non supporte** : chargement cumulatif apres qu'une autre fixture ait `persist` (sans `flush`) des Site dans la meme session → `findByName` via `findOneBy` n'inspecte pas l'unit-of-work et peut creer un doublon.
- **Non supporte** : renommage d'un site dans la fixture → le lookup par `name` rate, un nouveau site est cree, l'ancien reste en base si le purger est desactive.

## 9. Fixtures Sites

Trois sites de demonstration, avec des couleurs distinctes suffisamment contrastees pour un futur affichage visuel (ticket 3 — navbar) :

| Nom | Ville | CP | Couleur | Commentaire |
|-----|-------|-----|---------|-------------|
| 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). |
| Pommevic | Pommevic | 82400 | `#F59E0B` | Ambre (troisieme teinte nettement distincte). |

Les adresses completes sont des chaines multi-lignes (voie + CP/ville), cas nominal d'exploitation du type `TEXT` sur `full_address`.

### Ordre d'execution global des fixtures

`SitesFixtures` est une `Fixture` sans dependance : elle peut s'executer dans n'importe quel ordre relatif aux autres fixtures Core (`AppFixtures`). Aucune FK inter-modules dans ce ticket.

Le ticket 2 introduira probablement une relation `User ↔ Site` ; `SitesFixtures` devra alors etre declare comme dependance de `AppFixtures` (ou inversement, selon la direction de la FK) via `DependentFixtureInterface::getDependencies()`.

## 10. Plan de tests PHPUnit

Deux suites separees, motivation :
- `SiteTest` reste en `TestCase` pur (pas de kernel) pour tester le comportement mecanique de l'entite — rapide, zero dependance DB.
- `SiteValidationTest` utilise `KernelTestCase` pour avoir acces au validator applicatif, **indispensable** pour tester `UniqueEntity` dont le validator est backed par Doctrine et necessite donc un `ManagerRegistry` reel.

### `SiteTest` — tests unitaires purs

1. `testConstructorInitialState` : verifie que le constructeur positionne correctement les 5 champs metier et les deux timestamps (`DateTimeImmutable`).
2. `testCreatedAtAndUpdatedAtAreInitiallyEqual` : verifie l'invariant "a l'instanciation, `createdAt == updatedAt`".
3. `testOnPreUpdateAdvancesUpdatedAtOnly` : utilise `Reflection` pour forcer `updatedAt` a une valeur anterieure (`-1 hour`), appelle `onPreUpdate()`, et verifie que `updatedAt` avance strictement mais que `createdAt` reste immuable.
   - **Justification reflection** : eviter un `sleep/usleep` flaky en CI et lent.
4. `testSettersMutateFields` : verifie que les setters publics modifient correctement les champs metier.

### `SiteValidationTest` — tests d'integration validator

Bootstrap : `self::bootKernel()` dans `setUp()`, recuperation de `ValidatorInterface` et `EntityManagerInterface` depuis le container.

Tests de validation scalaire (via `DataProvider` PHPUnit 12+, attribut `#[DataProvider]`) :
1. `testValidSitePassesValidation` : un Site correct passe sans violation.
2. `testColorMustBeHexRrggbb` / `testValidColorsAreAccepted` : jeu de donnees invalide (`red`, `#FFF`, `FFFFFF`, `rgb(...)`, `#1234567`, `#12345G`, `""`) vs valide (`#ABCDEF`, `#abcdef`, `#0a1B2c`, `#000000`, `#FFFFFF`).
3. `testPostalCodeMustMatchFrFormat` / `testValidPostalCodesAreAccepted` : jeu de donnees invalide (`1234`, `123456`, `8610A`, `86-100`, `""`, `86 100`) vs valide (`86100`, `75001`, `97100`, `20000`).
4. `testBlankNameIsRejected`, `testBlankCityIsRejected`, `testBlankFullAddressIsRejected` : `NotBlank` sur chaque champ obligatoire.
5. `testNameLongerThan100CharsIsRejected`, `testCityLongerThan100CharsIsRejected` : `Length(max: 100)`.

Test d'unicite :
6. `testDuplicateNameIsRejected` : **auto-suffisant** — persiste lui-meme un site porteur d'un nom unique (`Test-Duplicate-<uniqid>`), flush, tente de valider un second Site avec le meme nom, verifie qu'au moins une violation porte `UniqueEntity::NOT_UNIQUE_ERROR` sur la property `name`, puis supprime le site en `finally`.
   - **Justification** : pas de dependance aux fixtures (robustesse, pas de couplage sur `Chatellerault`). Assertion precise sur le `code` de violation + `propertyPath`, pas sur le message (resistant aux traductions).

### Pattern `finally` pour cleanup

```php
try {
    $duplicate  = new Site($name, ...);
    $violations = $this->validator->validate($duplicate);
    // assertions...
} finally {
    $this->em->remove($original);
    $this->em->flush();
}
```

Garantit le cleanup meme si une assertion rate, sans dependre d'une transaction globale de test.

## 11. Risques et points d'attention

### Risque 1 — Mapping Doctrine inconditionnel

Le mapping `Sites:` est declare dans `doctrine.yaml` sans dependance a `config/modules.php`. Consequence : retirer `SitesModule::class` de `modules.php` ne desactive **pas** le mapping Doctrine ni la table `site`.

Decision assumee et alignee avec le traitement du module `Core` :
- La structure DB est "toujours la" (migrations jouees inconditionnellement).
- L'activation fonctionnelle (exposition des permissions, futurs endpoints) passe exclusivement par `modules.php`.

Cela doit etre **explicite dans `doctrine.yaml`** via un commentaire en tete du bloc `Sites:` pour eviter qu'un futur reviewer n'interprete le mapping comme un oubli.

### Risque 2 — Migration racine vs migration modulaire

La migration est placee dans `migrations/` et non dans `src/Module/Sites/Infrastructure/Doctrine/Migrations/`. C'est une exception documentee dans `CLAUDE.md` et dans le docblock de la migration elle-meme, motivee par un bug de tri alphabetique des `MigrationsComparator` en Doctrine Migrations 3.x lorsque plusieurs `migrations_paths` sont declares.

Consequence pour les tickets futurs :
- Tant que le bug n'est pas resolu, **toute nouvelle migration d'initialisation** (creation de table sur base vide) continuera d'aller au namespace racine.
- Les migrations applicatives (ajout de colonne, backfill) qui supposent un schema deja en place peuvent vivre dans le namespace modulaire, comme prevu.
- Une fois le bug resolu (comparator custom ou upgrade Doctrine), migrer les fichiers vers le namespace modulaire sera un simple `git mv` + ajustement du namespace PHP.

### Risque 3 — Idempotence des fixtures non cumulative

Le docblock de `SitesFixtures` declare explicitement les cas d'idempotence supportes et non supportes (cf. section 8). Ne pas promettre une robustesse que le pattern ne tient pas : si un futur ticket introduit une fixture persistant des Site **avant** `SitesFixtures` sans flush intermediaire, un doublon peut apparaitre. Le contrat ecrit permet au reviewer de ce futur ticket de reagir.

### Risque 4 — Regex couleur non normalisee

La regex `/^#[0-9A-Fa-f]{6}$/` accepte majuscules et minuscules. Les fixtures utilisent des majuscules ; si l'UI du ticket 4 permet de saisir en minuscules, deux couleurs "visuellement identiques" pourront coexister en base avec casse differente, cassant toute comparaison naive (`$a->color === $b->color`). A decider au ticket 4 : normaliser en uppercase a la persistance, ou assumer le stockage tel quel et normaliser uniquement a la comparaison.

### Risque 5 — Precision timestamp PostgreSQL TIMESTAMP(0)

PostgreSQL `TIMESTAMP(0)` ecrete a la seconde pres. Deux updates espaces de moins d'une seconde produisent le meme `updated_at` en base. Pas un probleme pour les cas d'usage metier de ce ticket (edition manuelle), mais a garder en tete si un ticket futur introduit un `updatedAt` comme cle de tri ou de detection de version optimiste.

## 12. Ordre d'exécution recommandé

1. **Exploration** — Lire le module Core (`CoreModule.php`, `User.php`, `Role.php`) pour aligner le style.
2. **Module declaration** — Creer `SitesModule.php` avec `permissions()`.
3. **Entite** — Creer `Site.php` avec tous les attributs Doctrine et contraintes de validation.
4. **Repository** — Creer `SiteRepositoryInterface.php` puis `DoctrineSiteRepository.php`.
5. **Configuration** — Enregistrer le mapping dans `doctrine.yaml`, l'alias dans `services.yaml`, le module dans `modules.php`.
6. **Migration** — Generer le fichier de migration (manuellement ou via `doctrine:migrations:diff` puis ajuster), jouer `make migration-migrate`.
7. **Fixtures** — Creer `SitesFixtures.php`, jouer `make fixtures` puis `make sync-permissions`.
8. **Tests unitaires** — Ecrire `SiteTest.php` (TestCase pur).
9. **Tests validation** — Ecrire `SiteValidationTest.php` (KernelTestCase).
10. **Validation DoD** — `make test-db-setup && make test` (doit passer 148/148), verifier que designer SitesModule ne casse rien.
11. **CS fixer** — `make php-cs-fixer-allow-risky FILES="src/Module/Sites tests/Module/Sites migrations/Version<timestamp>.php config/..."`.

## 13. Critères d'acceptation (DoD)

- [ ] `SitesModule.php` existe et declare exactement 2 permissions (`sites.view`, `sites.manage`) prefixees `sites.` conformement au format attendu par `SyncPermissionsCommand`.
- [ ] `SitesModule::class` est enregistre dans `config/modules.php` et active par defaut.
- [ ] Entite `Site` creee avec tous les champs, contraintes de validation (`NotBlank`, `Length`, `Regex hex`, `Regex CP FR`, `UniqueEntity`) et timestamps auto.
- [ ] `SiteRepositoryInterface` expose au minimum `findById`, `findByName`, `findAllOrderedByName`, `save`, `remove` ; `DoctrineSiteRepository` l'implemente.
- [ ] La migration existe dans `migrations/` (namespace `DoctrineMigrations`), cree la table `site` et l'index unique `uniq_site_name`, est jouable via `make migration-migrate`.
- [ ] `SitesFixtures` cree les 3 sites avec couleurs distinctes et docblock honnete sur son idempotence.
- [ ] `make fixtures` charge les 3 sites sans erreur et est rejouable apres purge.
- [ ] Apres `app:sync-permissions`, la table `permission` contient `sites.view` et `sites.manage` avec `module = 'sites'` et `orphan = false`.
- [ ] Le mapping `Sites:` est declare dans `doctrine.yaml` avec un commentaire explicite sur son caractere inconditionnel.
- [ ] L'alias `SiteRepositoryInterface → DoctrineSiteRepository` est declare dans `services.yaml`.
- [ ] `make test` passe 148/148 tests avec `SitesModule::class` active.
- [ ] `make test` passe 148/148 tests avec `SitesModule::class` commente dans `config/modules.php`.
- [ ] `make php-cs-fixer-allow-risky` ne signale aucune correction sur les fichiers du ticket.
- [ ] Aucun import direct depuis `src/Module/Core/...` vers `src/Module/Sites/...` ni l'inverse (independance des bounded contexts).
