# Module Sites (modèle, CRUD, preflight, wizard) — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal :** le premier module métier complet est en place : tables `sites` et `site_targets` dans DATA (UUIDv7, unicité `(org_id, domain)`, exactement une cible par défaut garantie par la base), CRUD FastAPI sous `CurrentOrgDep` avec pagination par curseur, endpoint synchrone `POST /sites/preflight` (DNS, home, robots.txt, sitemap, suggestion de cible), wizard 3 écrans `/sites/new`, liste `/sites`, page `/sites/[siteId]` avec les trois tuiles d'état vides, seed réel (`demo.seoinsight.example`), **une** migration Alembic nommée `sites`.

**Architecture :** inchangée (spec 2026-08-26) : `web/` ne parle à `api/` que côté serveur (Server Components / Server Actions, JWT EdDSA), toute réponse API traverse un schéma Zod, toute requête DATA est filtrée par `org_id`. La base AUTH ne bouge pas : `web/drizzle/` est intouché dans cette feature. Les jobs (crawl, GSC, imports) sont **hors périmètre** — la page du site affiche des états vides propres que le module Crawl remplira au même endroit.

**Tech Stack :** Postgres 18.6 Homebrew (`uuidv7()` natif) · uv, Python 3.13, FastAPI 0.141, SQLAlchemy 2 async + asyncpg, Alembic 1.19, Pydantic v2, PyJWT, httpx + idna (ajoutées en Task 1/4), pytest 9 (`-W error`, base `data_test`) · bun 1.4, Next 16.2, zod 4, vitest 4, oxlint/oxfmt, shadcn existants (`button`, `card`, `input`, `label`).

**Spec :** `docs/superpowers/specs/2026-08-31-module-sites-design.md` (source de vérité, §10 = décisions tranchées, §11 = ordre suivi ici), contexte : flow utilisateur `2026-08-28` (§2, §8.1), architecture `2026-08-26`.

**Branche :** une seule branche **`feature/sites`** pour les 7 tâches (une feature = au plus une migration par base), **un commit par tâche**, merge `--ff-only` dans `dev` en Task 7. **Pas de push** : l'utilisateur pousse lui-même.

## Global Constraints

- **CLIs officiels uniquement** pour toute ossature. Jamais de squelette écrit à la main. Aucune ossature nouvelle dans ce plan (pas de nouveau composant shadcn : `<select>`/radio natifs stylés Tailwind).
- **Dépendances** : JS — `sfw bun add <pkg>` ; ce plan n'ajoute **aucune** dépendance JS (`domainToUnicode` vient de `node:url`). Python — `uv add` ; ce plan ajoute **`idna`** (Task 1) et déplace **`httpx`** du groupe dev vers les dépendances principales (Task 4).
- **`web/` ne se connecte qu'à la base `auth`**, **`api/` qu'à la base `data`** (`data_test` pour les tests).
- **Appels `web/` → `api/` côté serveur uniquement** (Server Components / Server Actions). Jamais depuis le navigateur.
- **JWT** : EdDSA (défaut Better Auth), `expirationTime: "15m"`, payload = `sub`, `email`, `org_id`.
- **Next 16** : `error.tsx` en `'use client'`, chaque segment de route significatif a `loading.tsx` + `error.tsx` + `not-found.tsx` (spec §7.2). Server Components par défaut, `'use client'` aux feuilles. Mutations = Server Actions + validation Zod côté serveur.
- **Erreurs API** : Problem Details RFC 9457, `Content-Type: application/problem+json`. `ApiError` est attrapée dans les actions et son `detail` relayé — jamais d'erreur brute du serveur au navigateur.
- **Lint** : `bun run lint` (`oxlint --deny-warnings .`) et `bun run format:check` verts avant chaque commit web ; `uvx ruff check .` et `uvx ruff format --check .` verts avant chaque commit api. Tests : `bun run test` (vitest) et `uv run pytest -q -W error`.
- **Git** : branche `feature/sites` depuis `dev`, Conventional Commits, merge `--ff-only`, jamais de commit sur `main`, pas de push. **Jamais de heredoc dans la même commande qu'un `git commit`** (message via `-m`, un `-m` par paragraphe). Chaque commit porte les deux trailers `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` et `Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2` (deux `-m` supplémentaires).
- **Sandbox** : les agents atteignent Postgres (TCP et socket) — pytest et Alembic se lancent directement. Seules les vérifications **navigateur** (d3k) sont marquées **« (utilisateur) »**.
- Toute dette ou valeur temporaire est notée immédiatement dans `docs/pre-prod-checklist.md`.

Contraintes propres au module (valeurs exactes de la spec, non négociables) :

- **Unicité `(org_id, domain)`** : un domaine n'existe qu'une fois par organisation ; le multi-marché passe par `site_targets`. La contrainte SQL est le filet anti-course (`IntegrityError` → 409), la pré-vérification ne sert qu'au message propre.
- **Plafond de 20 cibles par site** : constante applicative `MAX_TARGETS_PER_SITE = 20` (pas de contrainte SQL).
- **Formats** : pays ISO 3166-1 alpha-2 (`^[A-Z]{2}$`), langue ISO 639-1 (`^[a-z]{2}$`) — validés par Pydantic **et** par des `CHECK` SQL.
- **Exactement une cible par défaut par site**, garanti par l'index unique partiel `UNIQUE (site_id) WHERE is_default` — pas seulement par l'application.
- **Preflight** : budget interne **8 s** (< 10 s, le timeout d'`apiFetch`), jamais un 5xx pour un site cible lent ; DNS échoué → autres checks `skipped` ; adresses toutes privées/réservées → `failed` (garde-fou SSRF) ; sitemap absent → `warning`, jamais `failed`. Résultats affichés, **jamais bloquants**.
- **Suppression** : FK `ON DELETE CASCADE` + confirmation UI forte (saisie du domaine).
- **Pagination par curseur** : `ORDER BY id DESC` (UUIDv7 = ordre de création), `WHERE id < :cursor`, défaut 25, max 100, curseur = UUID du dernier item en base64url, opaque ; illisible → 422 ; `next_cursor: null` en fin de liste.
- **Tout endpoint DATA prend `CurrentOrgDep`** ; un site d'une autre org → **404, jamais 403**.
- **Jamais de `SELECT *`** : les modèles ne portent que les colonnes des schémas de réponse (l'ORM émet la liste explicite), les cibles sont chargées par `selectinload` (volume borné par le plafond).
- **Domaine immuable** après création (absent du schéma PATCH, `extra="forbid"`) ; **`www.` conservé tel que saisi** ; forme stockée = ASCII/punycode, affichage Unicode côté `web/`.
- **Une seule migration nommée `sites`** en fin de feature (révisions `wip` jetables purgées avant le squash), SQL relu (aucun `DROP`, index et contraintes explicites).

---

## Carte des fichiers

### `api/`

| Fichier | Responsabilité |
|---|---|
| `app/sites/__init__.py` | **Créé.** Paquet du module |
| `app/sites/domain.py` | **Créé.** `normalize_domain(raw) -> str` pur, `DomainError` (Task 1) |
| `app/sites/models.py` | **Créé.** `Site`, `SiteTarget` (UUIDv7, contraintes, index unique partiel) (Task 2) |
| `app/sites/schemas.py` | **Créé.** Schémas Pydantic + `MAX_TARGETS_PER_SITE` (Task 3), `PreflightIn` (Task 4) |
| `app/sites/service.py` | **Créé.** CRUD filtré `org_id`, curseur base64url, promotion de défaut (Task 3) |
| `app/sites/router.py` | **Créé.** Routes `/sites` (Task 3), `/sites/preflight` (Task 4) |
| `app/sites/preflight.py` | **Créé.** Checks DNS/home/robots/sitemap, suggestion de cible, injection des mocks (Task 4) |
| `app/db/models.py` | Registre : ligne `from app.sites import models as _sites  # noqa: F401` (Task 2) |
| `app/main.py` | `include_router(sites_router)` (Task 3) |
| `app/seed.py` | `run(org_id)` crée le site de démo `demo.seoinsight.example`, idempotent (Task 5) |
| `alembic/versions/*_wip.py` | **Créé Task 2, supprimé Task 5** (rituel CLAUDE.md) |
| `alembic/versions/*_sites.py` | **Créé Task 5.** L'unique migration de la feature, SQL relu |
| `pyproject.toml` | `idna` (Task 1), `httpx` en dépendance principale (Task 4) |
| `tests/conftest.py` | Fixture `auth_headers(org_id)` (Task 3) |
| `tests/test_sites_domain.py` | **Créé.** `normalize_domain` pur (Task 1) |
| `tests/test_sites_crud.py` | **Créé.** Création/lecture/rename/suppression, cibles, cascade (Task 3) |
| `tests/test_sites_org.py` | **Créé.** Isolation inter-org, 404 (Task 3) |
| `tests/test_sites_cursor.py` | **Créé.** Parcours paginé, curseur corrompu, bornes de `limit` (Task 3) |
| `tests/test_sites_preflight.py` | **Créé.** Transport httpx et résolveur mockés, zéro réseau réel (Task 4) |
| `tests/test_seed.py` | Étendu : création du site de démo + idempotence (Task 5) |

### `web/`

| Fichier | Responsabilité |
|---|---|
| `lib/sites.ts` | **Créé.** Schémas Zod (`siteSchema`, `siteListSchema`, `preflightReportSchema`, `targetSchema`) + types inférés (Task 6) |
| `lib/format-domain.ts` (+ `.test.ts`) | **Créé.** Punycode → Unicode via `node:url`, repli sur l'entrée (Task 6) |
| `lib/locales.ts` (+ `.test.ts`) | **Créé.** `COUNTRIES` (~30), `LANGUAGES` (~20), `formatTarget` (Task 6) |
| `lib/sites.test.ts` | **Créé.** Parsing Zod sur fixtures, réponse malformée → throw (Task 6) |
| `lib/api.ts` | `apiFetch` : les 204 ne passent plus par `res.json()` (Task 7) |
| `app/(app)/sites/page.tsx` (+ `loading`/`error`/`not-found`) | **Créés.** Liste paginée, état vide avec CTA (Task 7) |
| `app/(app)/sites/new/page.tsx`, `actions.ts` (+ `loading`/`error`) | **Créés.** Coquille serveur, `preflightAction`, `createSiteAction` (Task 7) |
| `app/(app)/sites/[siteId]/page.tsx`, `actions.ts` (+ `loading`/`error`/`not-found`) | **Créés.** Page du site, tuiles, actions rename/cibles/suppression (Task 7) |
| `components/sites/site-wizard.tsx` | **Créé.** Les 3 écrans dans un composant client, état React (Task 7) |
| `components/sites/rename-site-form.tsx`, `targets-manager.tsx`, `delete-site-form.tsx` | **Créés.** Feuilles interactives de la page du site (Task 7) |
| `components/app/header.tsx` | Lien « Sites » (Task 7) |

---

### Task 1 : `normalize_domain` — normalisation et validation pures du domaine

**Files:**
- Create: `api/app/sites/__init__.py`, `api/app/sites/domain.py`, `api/tests/test_sites_domain.py`
- Modify: `api/pyproject.toml` (via `uv add idna`)

**Interfaces:**
- Produces : `normalize_domain(raw: str) -> str` (forme ASCII/punycode, minuscules, max 253) ; `DomainError(ValueError)` ; constante `MAX_DOMAIN_LENGTH = 253`. Consommée par le CRUD (Task 3), le preflight (Task 4).

- [ ] **Step 1 : Branche et dépendance**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git checkout dev && git pull && git checkout -b feature/sites
cd api && uv add idna
```

Expected : `idna` apparaît dans `[project.dependencies]` (elle était déjà présente transitivement — on la déclare parce qu'on l'importe).

- [ ] **Step 2 : Tests (RED)** — `api/tests/test_sites_domain.py` :

```python
import pytest

from app.sites.domain import DomainError, normalize_domain


@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("exemple.fr", "exemple.fr"),
        ("  Exemple.FR  ", "exemple.fr"),
        ("https://exemple.fr/page?x=1#top", "exemple.fr"),
        ("http://www.exemple.fr/", "www.exemple.fr"),
        ("exemple.fr/chemin/profond", "exemple.fr"),
        ("château.fr", "xn--chteau-xta.fr"),
        ("xn--chteau-xta.fr", "xn--chteau-xta.fr"),
        ("sous.domaine.exemple.co.uk", "sous.domaine.exemple.co.uk"),
    ],
)
def test_normalize_domain_accepts(raw: str, expected: str) -> None:
    assert normalize_domain(raw) == expected


@pytest.mark.parametrize(
    "raw",
    [
        "",
        "   ",
        "https://user:pass@exemple.fr/",
        "exemple.fr:8080",
        "https://exemple.fr:8443/",
        "192.168.1.10",
        "[::1]",
        "ftp://exemple.fr/",
        "exemple",
        "localhost",
        "exemple.fr.",
        "exem ple.fr",
        "a" * 63 + "." + "b" * 63 + "." + "c" * 63 + "." + "d" * 63 + ".fr",
    ],
)
def test_normalize_domain_rejects(raw: str) -> None:
    with pytest.raises(DomainError):
        normalize_domain(raw)


def test_domain_error_is_a_value_error() -> None:
    # Attrapable comme ValueError (validators Pydantic, appels directs).
    assert issubclass(DomainError, ValueError)
```

Run : `uv run pytest -q -W error tests/test_sites_domain.py` — Expected : `ModuleNotFoundError: No module named 'app.sites'`.

- [ ] **Step 3 : Implémentation (GREEN)** — `api/app/sites/__init__.py` vide, puis `api/app/sites/domain.py` :

```python
"""Normalisation et validation du domaine d'un site (spec 2026-08-31 §4).

Fonction pure, sans I/O. La forme stockée est l'hôte ASCII (punycode) ; l'affichage
Unicode est l'affaire de `web/lib/format-domain.ts`.
"""

import ipaddress
from urllib.parse import urlsplit

import idna

MAX_DOMAIN_LENGTH = 253


class DomainError(ValueError):
    """Domaine invalide — mappée en 422 Problem Details par les routes qui l'attrapent."""


def normalize_domain(raw: str) -> str:
    value = raw.strip()
    if not value:
        raise DomainError("Domaine vide")
    # Un hôte nu passe par le même parsing qu'une URL complète : chemin, query et fragment
    # sont ignorés (l'utilisateur colle souvent une URL profonde).
    if "://" not in value:
        value = f"http://{value}"
    parts = urlsplit(value)
    if parts.scheme not in ("http", "https"):
        raise DomainError(f"Schéma « {parts.scheme} » refusé (http ou https uniquement)")
    if "@" in parts.netloc:
        raise DomainError("Identifiants dans l'URL refusés")
    try:
        port = parts.port
    except ValueError as exc:
        raise DomainError("Port illisible") from exc
    if port is not None:
        raise DomainError("Port explicite refusé")
    host = parts.hostname
    if not host:
        raise DomainError("Aucun hôte dans l'entrée")
    if host.endswith("."):
        raise DomainError("Point final refusé")
    try:
        ipaddress.ip_address(host)
    except ValueError:
        pass
    else:
        raise DomainError("Adresse IP refusée : entrer un nom de domaine")
    host = host.lower()
    if host == "localhost" or "." not in host:
        raise DomainError("Hôte sans TLD refusé")
    # L'encodage IDNA valide au passage les labels (longueur, caractères, tirets).
    try:
        ascii_host = idna.encode(host, uts46=True).decode("ascii")
    except idna.IDNAError as exc:
        raise DomainError(f"Domaine invalide : {exc}") from exc
    if len(ascii_host) > MAX_DOMAIN_LENGTH:
        raise DomainError(f"Domaine trop long (> {MAX_DOMAIN_LENGTH} caractères)")
    return ascii_host
```

Run : `uv run pytest -q -W error` — Expected : **54 passed** (32 existants + 22).

- [ ] **Step 4 : Lint et commit**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api && uvx ruff check . && uvx ruff format --check .
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): normalisation et validation pures du domaine des sites (normalize_domain)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
```

---

### Task 2 : Modèles `sites` / `site_targets`, registre Alembic, révision `wip`

**Files:**
- Create: `api/app/sites/models.py`, `api/alembic/versions/<hash>_wip.py` (autogénérée, jetable)
- Modify: `api/app/db/models.py`

**Interfaces:**
- Produces : `Site` (`id` UUIDv7, `org_id`, `domain`, `name`, `created_at`, `updated_at`, relation `targets`) et `SiteTarget` (`id`, `site_id` FK CASCADE, `org_id` dénormalisé, `country`, `language`, `is_default`, `created_at`). Contraintes : `UNIQUE (org_id, domain)`, `UNIQUE (site_id, country, language)`, `CHECK` ISO, index unique partiel `UNIQUE (site_id) WHERE is_default`. Consommés par service (Task 3) et seed (Task 5).

- [ ] **Step 1 : `api/app/sites/models.py`**

```python
"""Tables du module Sites (spec 2026-08-31 §3). Colonnes = schémas de réponse, rien de plus."""

import uuid
from datetime import datetime

from sqlalchemy import (
    Boolean,
    CheckConstraint,
    DateTime,
    ForeignKey,
    Index,
    Text,
    UniqueConstraint,
    func,
    text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db.base import Base


class Site(Base):
    __tablename__ = "sites"
    # RETURNING sur INSERT/UPDATE : id (uuidv7), created_at, updated_at sont lus au flush,
    # jamais par un SELECT de rafraîchissement.
    __mapper_args__ = {"eager_defaults": True}

    # UUIDv7 natif Postgres 18 : ordre temporel (curseur sur une seule colonne), id opaque.
    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, server_default=text("uuidv7()")
    )
    # Id Better Auth opaque — jamais de FK inter-bases.
    org_id: Mapped[str] = mapped_column(Text, nullable=False, index=True)
    # Hôte normalisé (app.sites.domain), forme ASCII/punycode, max 253. Immuable.
    domain: Mapped[str] = mapped_column(Text, nullable=False)
    name: Mapped[str] = mapped_column(Text, nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, server_default=func.now()
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
    )

    # passive_deletes : c'est le ON DELETE CASCADE de la base qui supprime les cibles.
    targets: Mapped[list["SiteTarget"]] = relationship(
        back_populates="site",
        cascade="all, delete-orphan",
        passive_deletes=True,
        order_by="SiteTarget.id",
    )

    __table_args__ = (UniqueConstraint("org_id", "domain", name="uq_sites_org_id_domain"),)


class SiteTarget(Base):
    __tablename__ = "site_targets"
    __mapper_args__ = {"eager_defaults": True}

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, server_default=text("uuidv7()")
    )
    site_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("sites.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    # Dénormalisé (spec §2) : toute table métier porte org_id, filtrage systématique.
    org_id: Mapped[str] = mapped_column(Text, nullable=False, index=True)
    country: Mapped[str] = mapped_column(Text, nullable=False)  # ISO 3166-1 alpha-2
    language: Mapped[str] = mapped_column(Text, nullable=False)  # ISO 639-1
    is_default: Mapped[bool] = mapped_column(
        Boolean, nullable=False, server_default=text("false")
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, server_default=func.now()
    )

    site: Mapped[Site] = relationship(back_populates="targets")

    __table_args__ = (
        CheckConstraint("country ~ '^[A-Z]{2}$'", name="ck_site_targets_country"),
        CheckConstraint("language ~ '^[a-z]{2}$'", name="ck_site_targets_language"),
        UniqueConstraint(
            "site_id", "country", "language", name="uq_site_targets_site_country_language"
        ),
        # Exactement une cible par défaut par site, garanti par la base (spec §3.2).
        Index(
            "uq_site_targets_default", "site_id", unique=True, postgresql_where=text("is_default")
        ),
    )
```

- [ ] **Step 2 : Registre (RED sans lui)** — dans `api/app/db/models.py`, ajouter après `from app.db.base import Base` :

```python
from app.sites import models as _sites  # noqa: F401
```

Contre-épreuve avant la révision : `cd api && uv run alembic check` — Expected : échec `New upgrade operations detected` (les modèles existent, la migration pas encore). C'est le RED de cette tâche.

- [ ] **Step 3 : Révision `wip` et upgrade (GREEN)**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api
uv run alembic revision --autogenerate -m wip
```

Relire le fichier `alembic/versions/*_wip.py` généré : deux `op.create_table` (`sites` puis `site_targets`), `server_default=sa.text("uuidv7()")` sur les deux `id`, les deux `CheckConstraint`, `uq_sites_org_id_domain`, `uq_site_targets_site_country_language`, la FK `ondelete="CASCADE"`, les index `ix_*_org_id` / `ix_site_targets_site_id`, et **l'index unique partiel** `uq_site_targets_default` avec `postgresql_where=sa.text("is_default")` — s'il manque, l'ajouter à la main dans la révision (spec §3.4). Aucun `DROP`. Puis :

```bash
uv run alembic upgrade head
uv run pytest -q -W error
```

Expected : upgrade OK sur `data` ; **54 passed** — la fixture de session migre `data_test`, et `tests/test_migrations.py` (`alembic check` en sous-process) prouve que modèles et migration concordent. Si `alembic check` signalait l'index partiel comme différent, aligner le texte de `postgresql_where` sur la forme réfléchie par Postgres (`is_default`).

- [ ] **Step 4 : Lint et commit** (la révision `wip` est commitée : elle sera purgée au squash de la Task 5, conformément au rituel CLAUDE.md)

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api && uvx ruff check . && uvx ruff format --check .
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): modèles sites et site_targets, registre Alembic et révision wip" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
```

---

### Task 3 : CRUD sites + cibles, pagination par curseur, isolation par organisation

**Files:**
- Create: `api/app/sites/schemas.py`, `api/app/sites/service.py`, `api/app/sites/router.py`, `api/tests/test_sites_crud.py`, `api/tests/test_sites_org.py`, `api/tests/test_sites_cursor.py`
- Modify: `api/app/main.py`, `api/tests/conftest.py`

**Interfaces:**
- Consumes : `CurrentOrgDep` (`app.core.auth`), `SessionDep` (`app.db.engine`), `AppError`, `normalize_domain`, `Site`/`SiteTarget`.
- Produces : routes de la spec §5 (`POST/GET /sites`, `GET/PATCH/DELETE /sites/{site_id}`, `POST /sites/{site_id}/targets`, `PATCH/DELETE /sites/{site_id}/targets/{target_id}`) ; `MAX_TARGETS_PER_SITE = 20` ; `encode_cursor`/`decode_cursor` (base64url d'UUID, 422 si illisible) ; fixture session `auth_headers(org_id) -> dict` dans `conftest.py`.

- [ ] **Step 1 : Fixture `auth_headers`** — dans `api/tests/conftest.py`, ajouter `import time  # noqa: E402` sous `import jwt  # noqa: E402`, remplacer `from collections.abc import AsyncIterator  # noqa: E402` par `from collections.abc import AsyncIterator, Callable  # noqa: E402`, puis ajouter après la fixture `test_private_key` :

```python
@pytest.fixture(scope="session")
def auth_headers(test_private_key: Ed25519PrivateKey) -> Callable[[str], dict[str, str]]:
    """En-têtes Authorization pour une organisation donnée (JWT EdDSA signé par la clé de test)."""

    def _for(org_id: str) -> dict[str, str]:
        now = int(time.time())
        token = jwt.encode(
            {
                "sub": f"user_{org_id}",
                "email": "dev@test.local",
                "org_id": org_id,
                "iat": now,
                "exp": now + 900,
                "iss": "http://test-auth.local",
                "aud": "http://test-auth.local",
            },
            test_private_key,
            algorithm="EdDSA",
            headers={"kid": "test-key"},
        )
        return {"Authorization": f"Bearer {token}"}

    return _for
```

- [ ] **Step 2 : Tests CRUD (RED)** — `api/tests/test_sites_crud.py` :

```python
import uuid
from collections.abc import Callable
from typing import Any

from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.engine import get_engine
from app.sites.models import SiteTarget

Headers = Callable[[str], dict[str, str]]


def new_org() -> str:
    # data_test n'est pas vidée entre deux lancements de pytest : chaque test travaille
    # dans une organisation neuve pour rester rejouable.
    return f"org_{uuid.uuid4().hex[:12]}"


PAYLOAD: dict[str, Any] = {
    "domain": "https://www.Exemple.FR/page?x=1",
    "targets": [
        {"country": "FR", "language": "fr", "is_default": True},
        {"country": "BE", "language": "fr"},
    ],
}


async def create_site(
    client: AsyncClient, headers: dict[str, str], **overrides: Any
) -> dict[str, Any]:
    resp = await client.post("/sites", json={**PAYLOAD, **overrides}, headers=headers)
    assert resp.status_code == 201, resp.text
    return resp.json()


async def _count_targets(site_id: str) -> int:
    async with AsyncSession(get_engine()) as session:
        count = await session.scalar(
            select(func.count())
            .select_from(SiteTarget)
            .where(SiteTarget.site_id == uuid.UUID(site_id))
        )
    return count or 0


async def test_create_site_normalizes_and_sets_default(
    client: AsyncClient, auth_headers: Headers
) -> None:
    body = await create_site(client, auth_headers(new_org()))
    assert body["domain"] == "www.exemple.fr"  # normalisé, www conservé
    assert body["name"] == "www.exemple.fr"  # défaut applicatif : le domaine
    assert len(body["targets"]) == 2
    defaults = [t for t in body["targets"] if t["is_default"]]
    assert len(defaults) == 1 and defaults[0]["country"] == "FR"


async def test_create_site_with_explicit_name(client: AsyncClient, auth_headers: Headers) -> None:
    body = await create_site(client, auth_headers(new_org()), name="Boutique")
    assert body["name"] == "Boutique"


async def test_create_duplicate_domain_is_409(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    await create_site(client, headers)
    resp = await client.post("/sites", json=PAYLOAD, headers=headers)
    assert resp.status_code == 409
    assert resp.headers["content-type"].startswith("application/problem+json")
    assert "www.exemple.fr" in resp.json()["detail"]


async def test_create_invalid_domain_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    resp = await client.post(
        "/sites",
        json={**PAYLOAD, "domain": "192.168.1.10"},
        headers=auth_headers(new_org()),
    )
    assert resp.status_code == 422
    assert resp.headers["content-type"].startswith("application/problem+json")


async def test_create_two_defaults_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    targets = [
        {"country": "FR", "language": "fr", "is_default": True},
        {"country": "BE", "language": "fr", "is_default": True},
    ]
    resp = await client.post(
        "/sites", json={**PAYLOAD, "targets": targets}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 422


async def test_create_zero_default_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    targets = [{"country": "FR", "language": "fr"}]
    resp = await client.post(
        "/sites", json={**PAYLOAD, "targets": targets}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 422


async def test_create_duplicate_couple_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    targets = [
        {"country": "FR", "language": "fr", "is_default": True},
        {"country": "FR", "language": "fr"},
    ]
    resp = await client.post(
        "/sites", json={**PAYLOAD, "targets": targets}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 422


async def test_create_more_than_20_targets_is_422(
    client: AsyncClient, auth_headers: Headers
) -> None:
    targets = [
        {"country": f"{chr(65 + i)}A", "language": "aa", "is_default": i == 0} for i in range(21)
    ]
    resp = await client.post(
        "/sites", json={**PAYLOAD, "targets": targets}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 422


async def test_get_site_returns_targets(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    resp = await client.get(f"/sites/{created['id']}", headers=headers)
    assert resp.status_code == 200
    assert resp.json() == created


async def test_get_unknown_site_is_404(client: AsyncClient, auth_headers: Headers) -> None:
    resp = await client.get(f"/sites/{uuid.uuid4()}", headers=auth_headers(new_org()))
    assert resp.status_code == 404
    assert resp.headers["content-type"].startswith("application/problem+json")


async def test_rename_site(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    resp = await client.patch(
        f"/sites/{created['id']}", json={"name": "Nouveau nom"}, headers=headers
    )
    assert resp.status_code == 200
    assert resp.json()["name"] == "Nouveau nom"
    assert resp.json()["domain"] == created["domain"]


async def test_patch_domain_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    # Le domaine est immuable : absent du schéma PATCH (extra="forbid").
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    resp = await client.patch(
        f"/sites/{created['id']}",
        json={"name": "x", "domain": "autre.fr"},
        headers=headers,
    )
    assert resp.status_code == 422


async def test_delete_site_removes_it(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    resp = await client.delete(f"/sites/{created['id']}", headers=headers)
    assert resp.status_code == 204
    resp = await client.get(f"/sites/{created['id']}", headers=headers)
    assert resp.status_code == 404


async def test_delete_site_cascades_targets(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    assert await _count_targets(created["id"]) == 2
    await client.delete(f"/sites/{created['id']}", headers=headers)
    assert await _count_targets(created["id"]) == 0


async def test_add_target(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    resp = await client.post(
        f"/sites/{created['id']}/targets",
        json={"country": "CH", "language": "fr"},
        headers=headers,
    )
    assert resp.status_code == 201
    body = resp.json()
    assert (body["country"], body["language"], body["is_default"]) == ("CH", "fr", False)


async def test_add_duplicate_target_is_409(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    resp = await client.post(
        f"/sites/{created['id']}/targets",
        json={"country": "BE", "language": "fr"},
        headers=headers,
    )
    assert resp.status_code == 409


async def test_add_target_on_unknown_site_is_404(
    client: AsyncClient, auth_headers: Headers
) -> None:
    resp = await client.post(
        f"/sites/{uuid.uuid4()}/targets",
        json={"country": "FR", "language": "fr"},
        headers=auth_headers(new_org()),
    )
    assert resp.status_code == 404


async def test_add_target_beyond_cap_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    targets = [
        {"country": f"{chr(65 + i)}A", "language": "aa", "is_default": i == 0} for i in range(20)
    ]
    created = await create_site(client, headers, targets=targets)
    resp = await client.post(
        f"/sites/{created['id']}/targets",
        json={"country": "ZZ", "language": "zz"},
        headers=headers,
    )
    assert resp.status_code == 422
    assert "20" in resp.json()["detail"]


async def test_promote_target_demotes_previous_default(
    client: AsyncClient, auth_headers: Headers
) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    secondary = next(t for t in created["targets"] if not t["is_default"])
    resp = await client.patch(
        f"/sites/{created['id']}/targets/{secondary['id']}",
        json={"is_default": True},
        headers=headers,
    )
    assert resp.status_code == 200
    assert resp.json()["is_default"] is True
    site = (await client.get(f"/sites/{created['id']}", headers=headers)).json()
    defaults = [t["id"] for t in site["targets"] if t["is_default"]]
    assert defaults == [secondary["id"]]  # l'ancienne défaut est démise


async def test_promote_with_false_is_422(client: AsyncClient, auth_headers: Headers) -> None:
    # On promeut une autre cible, on ne « dé-défaute » pas (spec §5).
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    default = next(t for t in created["targets"] if t["is_default"])
    resp = await client.patch(
        f"/sites/{created['id']}/targets/{default['id']}",
        json={"is_default": False},
        headers=headers,
    )
    assert resp.status_code == 422


async def test_delete_target(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    secondary = next(t for t in created["targets"] if not t["is_default"])
    resp = await client.delete(
        f"/sites/{created['id']}/targets/{secondary['id']}", headers=headers
    )
    assert resp.status_code == 204
    site = (await client.get(f"/sites/{created['id']}", headers=headers)).json()
    assert len(site["targets"]) == 1


async def test_delete_default_target_is_409(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    created = await create_site(client, headers)
    default = next(t for t in created["targets"] if t["is_default"])
    resp = await client.delete(
        f"/sites/{created['id']}/targets/{default['id']}", headers=headers
    )
    assert resp.status_code == 409
```

- [ ] **Step 3 : Tests d'isolation par organisation (RED)** — `api/tests/test_sites_org.py` :

```python
from httpx import AsyncClient

from tests.test_me import make_token
from tests.test_sites_crud import Headers, create_site, new_org


async def test_get_site_of_other_org_is_404(client: AsyncClient, auth_headers: Headers) -> None:
    created = await create_site(client, auth_headers(new_org()))
    resp = await client.get(f"/sites/{created['id']}", headers=auth_headers(new_org()))
    assert resp.status_code == 404  # jamais 403 : ne pas révéler l'existence


async def test_patch_site_of_other_org_is_404(client: AsyncClient, auth_headers: Headers) -> None:
    created = await create_site(client, auth_headers(new_org()))
    resp = await client.patch(
        f"/sites/{created['id']}", json={"name": "pirate"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 404


async def test_delete_site_of_other_org_is_404(
    client: AsyncClient, auth_headers: Headers
) -> None:
    owner = auth_headers(new_org())
    created = await create_site(client, owner)
    resp = await client.delete(f"/sites/{created['id']}", headers=auth_headers(new_org()))
    assert resp.status_code == 404
    assert (await client.get(f"/sites/{created['id']}", headers=owner)).status_code == 200


async def test_list_only_own_org(client: AsyncClient, auth_headers: Headers) -> None:
    await create_site(client, auth_headers(new_org()))
    resp = await client.get("/sites", headers=auth_headers(new_org()))
    assert resp.status_code == 200
    assert resp.json() == {"items": [], "next_cursor": None}


async def test_sites_without_active_org_is_404(client: AsyncClient, test_private_key) -> None:
    token = make_token(test_private_key, org_id=None)
    resp = await client.get("/sites", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 404
    assert resp.json()["detail"] == "Aucune organisation active"
```

- [ ] **Step 4 : Tests du curseur (RED)** — `api/tests/test_sites_cursor.py` :

```python
import pytest
from httpx import AsyncClient

from tests.test_sites_crud import Headers, create_site, new_org


async def test_cursor_walks_all_pages_without_duplicates(
    client: AsyncClient, auth_headers: Headers
) -> None:
    headers = auth_headers(new_org())
    created = [
        (await create_site(client, headers, domain=f"site-{i}.exemple.fr"))["id"]
        for i in range(7)
    ]
    seen: list[str] = []
    cursor: str | None = None
    pages = 0
    while True:
        params: dict[str, str | int] = {"limit": 3}
        if cursor is not None:
            params["cursor"] = cursor
        resp = await client.get("/sites", params=params, headers=headers)
        assert resp.status_code == 200
        data = resp.json()
        seen += [item["id"] for item in data["items"]]
        pages += 1
        cursor = data["next_cursor"]
        if cursor is None:
            break
    assert pages == 3  # 3 + 3 + 1
    assert seen == list(reversed(created))  # plus récent d'abord, ni trou ni doublon


@pytest.mark.parametrize("cursor", ["%%%", "AAAA", "cGFzLXVuLXV1aWQ"])
async def test_malformed_cursor_is_422(
    client: AsyncClient, auth_headers: Headers, cursor: str
) -> None:
    resp = await client.get("/sites", params={"cursor": cursor}, headers=auth_headers(new_org()))
    assert resp.status_code == 422
    assert resp.headers["content-type"].startswith("application/problem+json")


@pytest.mark.parametrize("limit", [0, 101])
async def test_limit_out_of_bounds_is_422(
    client: AsyncClient, auth_headers: Headers, limit: int
) -> None:
    resp = await client.get("/sites", params={"limit": limit}, headers=auth_headers(new_org()))
    assert resp.status_code == 422


async def test_default_limit_is_25(client: AsyncClient, auth_headers: Headers) -> None:
    headers = auth_headers(new_org())
    for i in range(26):
        await create_site(client, headers, domain=f"page-{i}.exemple.fr")
    resp = await client.get("/sites", headers=headers)
    data = resp.json()
    assert len(data["items"]) == 25
    assert data["next_cursor"] is not None
```

Run : `uv run pytest -q -W error tests/test_sites_crud.py tests/test_sites_org.py tests/test_sites_cursor.py` — Expected : échecs en série sur `assert resp.status_code == …` (les routes n'existent pas : FastAPI répond 404 Problem Details partout). C'est le RED.

- [ ] **Step 5 : `api/app/sites/schemas.py` (GREEN, 1/3)**

```python
"""Schémas Pydantic du module Sites (spec 2026-08-31 §5)."""

import uuid
from datetime import datetime
from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator

# Garde-fou anti-abus (décision §10.3) : constante applicative, pas de contrainte SQL.
MAX_TARGETS_PER_SITE = 20

CountryCode = Annotated[str, StringConstraints(pattern=r"^[A-Z]{2}$")]  # ISO 3166-1 alpha-2
LanguageCode = Annotated[str, StringConstraints(pattern=r"^[a-z]{2}$")]  # ISO 639-1


class TargetIn(BaseModel):
    country: CountryCode
    language: LanguageCode
    is_default: bool = False


class SiteCreate(BaseModel):
    domain: Annotated[str, StringConstraints(min_length=1, max_length=300)]
    name: Annotated[str, StringConstraints(min_length=1, max_length=200)] | None = None
    targets: Annotated[list[TargetIn], Field(min_length=1, max_length=MAX_TARGETS_PER_SITE)]

    @model_validator(mode="after")
    def _targets_coherent(self) -> "SiteCreate":
        if sum(t.is_default for t in self.targets) != 1:
            raise ValueError("exactement une cible doit être marquée par défaut")
        couples = [(t.country, t.language) for t in self.targets]
        if len(set(couples)) != len(couples):
            raise ValueError("couples (pays, langue) en double dans les cibles")
        return self


class SiteUpdate(BaseModel):
    # Le domaine est immuable : absent de ce schéma, et tout champ inattendu
    # (dont `domain`) est un 422 grâce à extra="forbid".
    model_config = ConfigDict(extra="forbid")

    name: Annotated[str, StringConstraints(min_length=1, max_length=200)]


class TargetCreate(BaseModel):
    country: CountryCode
    language: LanguageCode


class TargetUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    # `false` refusé : on promeut une autre cible, on ne « dé-défaute » pas (spec §5).
    is_default: Literal[True]


class TargetOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    country: str
    language: str
    is_default: bool
    created_at: datetime


class SiteOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    domain: str
    name: str
    created_at: datetime
    updated_at: datetime
    targets: list[TargetOut]


class SiteListOut(BaseModel):
    items: list[SiteOut]
    next_cursor: str | None
```

- [ ] **Step 6 : `api/app/sites/service.py` (GREEN, 2/3)**

```python
"""Lectures/écritures du module Sites — toutes filtrées par org_id (spec 2026-08-31 §5).

Jamais de SELECT * : l'ORM émet la liste explicite des colonnes mappées, qui sont
exactement celles des schémas de réponse ; les cibles sont chargées par selectinload
(volume borné par MAX_TARGETS_PER_SITE).
"""

import base64
import binascii
import uuid

from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from app.core.errors import AppError
from app.sites.domain import DomainError, normalize_domain
from app.sites.models import Site, SiteTarget
from app.sites.schemas import MAX_TARGETS_PER_SITE, SiteCreate, SiteUpdate, TargetCreate


def encode_cursor(site_id: uuid.UUID) -> str:
    return base64.urlsafe_b64encode(site_id.bytes).decode("ascii").rstrip("=")


def decode_cursor(cursor: str) -> uuid.UUID:
    padded = cursor + "=" * (-len(cursor) % 4)
    try:
        raw = base64.urlsafe_b64decode(padded.encode("ascii"))
        return uuid.UUID(bytes=raw)
    except (ValueError, binascii.Error, UnicodeEncodeError) as exc:
        raise AppError(422, "Validation error", "Curseur illisible") from exc


def _duplicate_domain(domain: str) -> AppError:
    return AppError(
        409, "Conflict", f"Le domaine {domain} est déjà suivi dans cette organisation"
    )


async def create_site(session: AsyncSession, org_id: str, payload: SiteCreate) -> Site:
    try:
        domain = normalize_domain(payload.domain)
    except DomainError as exc:
        raise AppError(422, "Validation error", str(exc)) from exc
    # Pré-vérification pour un message propre ; la contrainte UNIQUE (org_id, domain)
    # reste le filet anti-course.
    duplicate = await session.scalar(
        select(Site.id).where(Site.org_id == org_id, Site.domain == domain)
    )
    if duplicate is not None:
        raise _duplicate_domain(domain)
    site = Site(org_id=org_id, domain=domain, name=payload.name or domain)
    site.targets = [
        SiteTarget(
            org_id=org_id, country=t.country, language=t.language, is_default=t.is_default
        )
        for t in payload.targets
    ]
    session.add(site)
    try:
        await session.commit()
    except IntegrityError as exc:
        await session.rollback()
        raise _duplicate_domain(domain) from exc
    return site


async def list_sites(
    session: AsyncSession, org_id: str, *, cursor: str | None, limit: int
) -> tuple[list[Site], str | None]:
    stmt = (
        select(Site)
        .options(selectinload(Site.targets))
        .where(Site.org_id == org_id)
        .order_by(Site.id.desc())
        .limit(limit + 1)
    )
    if cursor is not None:
        stmt = stmt.where(Site.id < decode_cursor(cursor))
    sites = list((await session.scalars(stmt)).all())
    next_cursor: str | None = None
    if len(sites) > limit:
        sites = sites[:limit]
        next_cursor = encode_cursor(sites[-1].id)
    return sites, next_cursor


async def get_site(session: AsyncSession, org_id: str, site_id: uuid.UUID) -> Site:
    site = await session.scalar(
        select(Site)
        .options(selectinload(Site.targets))
        .where(Site.id == site_id, Site.org_id == org_id)
    )
    if site is None:
        # 404 et non 403 : un site d'une autre org n'existe pas (architecture §9).
        raise AppError(404, "Not Found", "Site inconnu")
    return site


async def rename_site(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, payload: SiteUpdate
) -> Site:
    site = await get_site(session, org_id, site_id)
    site.name = payload.name
    await session.commit()  # updated_at : onupdate=func.now(), relu via eager_defaults
    return site


async def delete_site(session: AsyncSession, org_id: str, site_id: uuid.UUID) -> None:
    site = await get_site(session, org_id, site_id)
    await session.delete(site)  # les cibles tombent par ON DELETE CASCADE
    await session.commit()


def _duplicate_couple(payload: TargetCreate) -> AppError:
    return AppError(
        409,
        "Conflict",
        f"La cible {payload.country}/{payload.language} existe déjà pour ce site",
    )


async def add_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, payload: TargetCreate
) -> SiteTarget:
    site = await get_site(session, org_id, site_id)
    if len(site.targets) >= MAX_TARGETS_PER_SITE:
        raise AppError(
            422, "Validation error", f"Plafond de {MAX_TARGETS_PER_SITE} cibles par site atteint"
        )
    if any(
        t.country == payload.country and t.language == payload.language for t in site.targets
    ):
        raise _duplicate_couple(payload)
    target = SiteTarget(
        site_id=site.id,
        org_id=org_id,
        country=payload.country,
        language=payload.language,
        is_default=False,
    )
    session.add(target)
    try:
        await session.commit()
    except IntegrityError as exc:
        await session.rollback()
        raise _duplicate_couple(payload) from exc
    return target


async def _get_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, target_id: uuid.UUID
) -> SiteTarget:
    target = await session.scalar(
        select(SiteTarget).where(
            SiteTarget.id == target_id,
            SiteTarget.site_id == site_id,
            SiteTarget.org_id == org_id,
        )
    )
    if target is None:
        raise AppError(404, "Not Found", "Cible inconnue")
    return target


async def promote_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, target_id: uuid.UUID
) -> SiteTarget:
    target = await _get_target(session, org_id, site_id, target_id)
    # Deux UPDATE explicites dans la même transaction ; l'index unique partiel garantit
    # l'invariant « exactement une défaut » même en cas de course. Pas d'affectation ORM :
    # si la cible est déjà la défaut, `True → True` ne serait pas flushé alors que le
    # premier UPDATE l'aurait démise.
    await session.execute(
        update(SiteTarget)
        .where(
            SiteTarget.site_id == site_id,
            SiteTarget.is_default,
            SiteTarget.id != target.id,
        )
        .values(is_default=False)
    )
    await session.execute(
        update(SiteTarget).where(SiteTarget.id == target.id).values(is_default=True)
    )
    try:
        await session.commit()
    except IntegrityError as exc:
        await session.rollback()
        raise AppError(409, "Conflict", "Promotion concurrente, réessayer") from exc
    await session.refresh(target)
    return target


async def delete_target(
    session: AsyncSession, org_id: str, site_id: uuid.UUID, target_id: uuid.UUID
) -> None:
    target = await _get_target(session, org_id, site_id, target_id)
    if target.is_default:
        # La dernière cible est toujours la défaut : elle n'est donc jamais supprimable.
        raise AppError(
            409,
            "Conflict",
            "La cible par défaut ne peut pas être supprimée : en promouvoir une autre d'abord",
        )
    await session.delete(target)
    await session.commit()
```

- [ ] **Step 7 : `api/app/sites/router.py` et enregistrement (GREEN, 3/3)**

```python
import uuid
from typing import Annotated

from fastapi import APIRouter, Query

from app.core.auth import CurrentOrgDep
from app.db.engine import SessionDep
from app.sites import service
from app.sites.schemas import (
    SiteCreate,
    SiteListOut,
    SiteOut,
    SiteUpdate,
    TargetCreate,
    TargetOut,
    TargetUpdate,
)

router = APIRouter(prefix="/sites", tags=["sites"])


@router.post("", status_code=201)
async def create_site(user: CurrentOrgDep, session: SessionDep, payload: SiteCreate) -> SiteOut:
    return SiteOut.model_validate(await service.create_site(session, user.org_id, payload))


@router.get("")
async def list_sites(
    user: CurrentOrgDep,
    session: SessionDep,
    cursor: str | None = None,
    limit: Annotated[int, Query(ge=1, le=100)] = 25,
) -> SiteListOut:
    sites, next_cursor = await service.list_sites(
        session, user.org_id, cursor=cursor, limit=limit
    )
    return SiteListOut(
        items=[SiteOut.model_validate(site) for site in sites], next_cursor=next_cursor
    )


@router.get("/{site_id}")
async def get_site(user: CurrentOrgDep, session: SessionDep, site_id: uuid.UUID) -> SiteOut:
    return SiteOut.model_validate(await service.get_site(session, user.org_id, site_id))


@router.patch("/{site_id}")
async def rename_site(
    user: CurrentOrgDep, session: SessionDep, site_id: uuid.UUID, payload: SiteUpdate
) -> SiteOut:
    return SiteOut.model_validate(
        await service.rename_site(session, user.org_id, site_id, payload)
    )


@router.delete("/{site_id}", status_code=204)
async def delete_site(user: CurrentOrgDep, session: SessionDep, site_id: uuid.UUID) -> None:
    await service.delete_site(session, user.org_id, site_id)


@router.post("/{site_id}/targets", status_code=201)
async def add_target(
    user: CurrentOrgDep, session: SessionDep, site_id: uuid.UUID, payload: TargetCreate
) -> TargetOut:
    return TargetOut.model_validate(
        await service.add_target(session, user.org_id, site_id, payload)
    )


@router.patch("/{site_id}/targets/{target_id}")
async def promote_target(
    user: CurrentOrgDep,
    session: SessionDep,
    site_id: uuid.UUID,
    target_id: uuid.UUID,
    _payload: TargetUpdate,
) -> TargetOut:
    return TargetOut.model_validate(
        await service.promote_target(session, user.org_id, site_id, target_id)
    )


@router.delete("/{site_id}/targets/{target_id}", status_code=204)
async def delete_target(
    user: CurrentOrgDep,
    session: SessionDep,
    site_id: uuid.UUID,
    target_id: uuid.UUID,
) -> None:
    await service.delete_target(session, user.org_id, site_id, target_id)
```

Dans `api/app/main.py` : ajouter `from app.sites.router import router as sites_router` sous l'import du router `me`, et `app.include_router(sites_router)` sous `app.include_router(me_router)`.

Run : `uv run pytest -q -W error` — Expected : **88 passed** (54 + 22 crud + 5 org + 7 curseur).

- [ ] **Step 8 : Lint et commit**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api && uvx ruff check . && uvx ruff format --check .
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): CRUD sites et cibles, pagination par curseur sous CurrentOrgDep" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
```

---

### Task 4 : Preflight — DNS, page d'accueil, robots.txt, sitemap, suggestion de cible

**Files:**
- Create: `api/app/sites/preflight.py`, `api/tests/test_sites_preflight.py`
- Modify: `api/app/sites/schemas.py` (`PreflightIn`), `api/app/sites/router.py` (route), `api/pyproject.toml` (httpx en dépendance principale)

**Interfaces:**
- Produces : `run_preflight(host: str) -> PreflightReport` ; modèles `CheckResult` (`status: ok|warning|failed|skipped`, `detail`), `PreflightChecks`, `SuggestedTarget`, `PreflightReport` ; variables de module injectables `resolver` (DNS) et `transport` (httpx), remplacées par `monkeypatch` en test — même approche que `set_jwks_client`. Route `POST /sites/preflight` sous `CurrentOrgDep`.
- Budget : DNS 2 s, chaque fetch 5 s, **budget global 8 s** (< 10 s d'`apiFetch`). Jamais de 5xx pour un site cible lent.

- [ ] **Step 1 : Dépendance** — `httpx` est déjà dans le venv (extra `fastapi[standard]` + groupe dev) mais pas déclarée comme dépendance de prod :

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api && uv remove --group dev httpx && uv add httpx
```

Expected : `httpx>=0.28.1` dans `[project.dependencies]`, absent de `[dependency-groups].dev`.

- [ ] **Step 2 : Tests (RED)** — `api/tests/test_sites_preflight.py` :

```python
import socket

import httpx
import pytest
from httpx import AsyncClient

import app.sites.preflight as preflight
from tests.test_me import make_token
from tests.test_sites_crud import Headers, new_org

HOME_HTML = b'<!doctype html><html lang="fr-FR"><head><title>ok</title></head><body></body></html>'


@pytest.fixture
def public_dns(monkeypatch: pytest.MonkeyPatch) -> None:
    async def _resolve(_host: str) -> list[str]:
        return ["93.184.216.34"]

    monkeypatch.setattr(preflight, "resolver", _resolve)


def _happy_handler(request: httpx.Request) -> httpx.Response:
    url = str(request.url)
    if url == "https://exemple.fr/":
        return httpx.Response(301, headers={"location": "https://www.exemple.fr/"})
    if url == "https://www.exemple.fr/":
        return httpx.Response(200, content=HOME_HTML)
    if url == "https://exemple.fr/robots.txt":
        return httpx.Response(
            200, text="User-agent: *\nSitemap: https://exemple.fr/sitemap.xml\n"
        )
    if url == "https://exemple.fr/sitemap.xml":
        return httpx.Response(
            200, content=b"<urlset/>", headers={"content-type": "application/xml"}
        )
    return httpx.Response(404)


async def test_preflight_all_ok(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_happy_handler))
    resp = await client.post(
        "/sites/preflight",
        json={"domain": "https://exemple.fr/"},
        headers=auth_headers(new_org()),
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["domain"] == "exemple.fr"
    assert {name: check["status"] for name, check in body["checks"].items()} == {
        "dns": "ok", "home": "ok", "robots": "ok", "sitemap": "ok"
    }
    assert "1 directive Sitemap" in body["checks"]["robots"]["detail"]
    assert body["final_host"] == "www.exemple.fr"  # redirection détectée (spec §10.4)
    assert body["suggested_target"] == {"country": "FR", "language": "fr"}


async def test_preflight_dns_failure_skips_fetches(
    client: AsyncClient, auth_headers: Headers, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _resolve(_host: str) -> list[str]:
        raise socket.gaierror(8, "nodename nor servname provided")

    monkeypatch.setattr(preflight, "resolver", _resolve)

    def _no_fetch(request: httpx.Request) -> httpx.Response:
        raise AssertionError("aucun fetch ne doit partir quand le DNS échoue")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_fetch))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "failed"
    statuses = {body["checks"][name]["status"] for name in ("home", "robots", "sitemap")}
    assert statuses == {"skipped"}


async def test_preflight_private_ip_is_failed(
    client: AsyncClient, auth_headers: Headers, monkeypatch: pytest.MonkeyPatch
) -> None:
    async def _resolve(_host: str) -> list[str]:
        return ["192.168.1.10", "10.0.0.4"]

    monkeypatch.setattr(preflight, "resolver", _resolve)

    def _no_fetch(request: httpx.Request) -> httpx.Response:
        raise AssertionError("garde-fou SSRF : aucun fetch vers un hôte privé")

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_fetch))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert body["checks"]["dns"]["status"] == "failed"
    assert "non public" in body["checks"]["dns"]["detail"]
    assert body["checks"]["home"]["status"] == "skipped"


def _no_robots_handler(request: httpx.Request) -> httpx.Response:
    url = str(request.url)
    if url == "https://exemple.fr/":
        return httpx.Response(200, content=HOME_HTML)
    if url == "https://exemple.fr/sitemap.xml":
        return httpx.Response(200, content=b"<urlset/>")
    return httpx.Response(404)


async def test_preflight_robots_absent_is_warning(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_no_robots_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    body = resp.json()
    assert body["checks"]["robots"]["status"] == "warning"
    assert body["checks"]["sitemap"]["status"] == "ok"  # repli /sitemap.xml
    assert body["final_host"] is None  # pas de redirection


async def test_preflight_sitemap_missing_is_warning(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _handler(request: httpx.Request) -> httpx.Response:
        if str(request.url) == "https://exemple.fr/":
            return httpx.Response(200, content=HOME_HTML)
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.json()["checks"]["sitemap"]["status"] == "warning"  # jamais failed


async def test_preflight_lang_attribute_wins(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _handler(request: httpx.Request) -> httpx.Response:
        if str(request.url) == "https://exemple.com/":
            return httpx.Response(200, content=b'<html lang="de-CH"><body></body></html>')
        return httpx.Response(404)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_handler))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.com"}, headers=auth_headers(new_org())
    )
    # lang="de-CH" → sous-étiquette primaire ; .com hors table ccTLD → US.
    assert resp.json()["suggested_target"] == {"country": "US", "language": "de"}


async def test_preflight_timeouts_fail_without_500(
    client: AsyncClient,
    auth_headers: Headers,
    public_dns: None,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _slow(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectTimeout("délai simulé", request=request)

    monkeypatch.setattr(preflight, "transport", httpx.MockTransport(_slow))
    resp = await client.post(
        "/sites/preflight", json={"domain": "exemple.fr"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 200  # le site cible lent n'est pas une erreur serveur
    body = resp.json()
    for name in ("home", "robots", "sitemap"):
        assert body["checks"][name]["status"] == "failed"
        assert "délai" in body["checks"][name]["detail"]


async def test_preflight_invalid_domain_is_422(
    client: AsyncClient, auth_headers: Headers
) -> None:
    resp = await client.post(
        "/sites/preflight", json={"domain": "192.168.1.10"}, headers=auth_headers(new_org())
    )
    assert resp.status_code == 422
    assert resp.headers["content-type"].startswith("application/problem+json")


async def test_preflight_without_active_org_is_404(
    client: AsyncClient, test_private_key
) -> None:
    token = make_token(test_private_key, org_id=None)
    resp = await client.post(
        "/sites/preflight",
        json={"domain": "exemple.fr"},
        headers={"Authorization": f"Bearer {token}"},
    )
    assert resp.status_code == 404
```

Run : `uv run pytest -q -W error tests/test_sites_preflight.py` — Expected : `ModuleNotFoundError: No module named 'app.sites.preflight'`.

- [ ] **Step 3 : `api/app/sites/preflight.py` (GREEN, 1/2)**

```python
"""Preflight d'un domaine : DNS, page d'accueil, robots.txt, sitemap (spec 2026-08-31 §6).

Synchrone, sans persistance, lecture seule. `resolver` et `transport` sont des variables
de module remplaçables en test (monkeypatch) — même approche que `set_jwks_client`.
"""

import asyncio
import ipaddress
import re
import socket
from collections.abc import Awaitable, Callable
from typing import Literal

import httpx
from pydantic import BaseModel

DNS_TIMEOUT = 2.0
FETCH_TIMEOUT = 5.0
TOTAL_BUDGET = 8.0  # < 10 s, le timeout d'apiFetch côté web
HOME_MAX_BYTES = 64 * 1024
ROBOTS_MAX_BYTES = 512 * 1024
MAX_REDIRECTS = 5
USER_AGENT = "seoinsight-preflight/1.0"

# ccTLD → (pays ISO 3166-1 alpha-2, langue ISO 639-1 associée). Suggestion, pas décision.
CCTLD: dict[str, tuple[str, str]] = {
    "fr": ("FR", "fr"),
    "be": ("BE", "fr"),
    "ch": ("CH", "de"),
    "lu": ("LU", "fr"),
    "de": ("DE", "de"),
    "at": ("AT", "de"),
    "uk": ("GB", "en"),
    "ie": ("IE", "en"),
    "es": ("ES", "es"),
    "pt": ("PT", "pt"),
    "it": ("IT", "it"),
    "nl": ("NL", "nl"),
    "pl": ("PL", "pl"),
    "cz": ("CZ", "cs"),
    "se": ("SE", "sv"),
    "no": ("NO", "no"),
    "dk": ("DK", "da"),
    "fi": ("FI", "fi"),
    "us": ("US", "en"),
    "ca": ("CA", "en"),
}

CheckStatus = Literal["ok", "warning", "failed", "skipped"]


class CheckResult(BaseModel):
    status: CheckStatus
    detail: str | None = None


class PreflightChecks(BaseModel):
    dns: CheckResult
    home: CheckResult
    robots: CheckResult
    sitemap: CheckResult


class SuggestedTarget(BaseModel):
    country: str
    language: str


class PreflightReport(BaseModel):
    domain: str
    checks: PreflightChecks
    final_host: str | None
    suggested_target: SuggestedTarget


Resolver = Callable[[str], Awaitable[list[str]]]


async def _default_resolver(host: str) -> list[str]:
    loop = asyncio.get_running_loop()
    infos = await asyncio.wait_for(
        loop.getaddrinfo(host, 443, type=socket.SOCK_STREAM), DNS_TIMEOUT
    )
    return [str(info[4][0]) for info in infos]


# Points d'injection des tests (monkeypatch.setattr(preflight, "resolver", …)).
resolver: Resolver = _default_resolver
transport: httpx.AsyncBaseTransport | None = None


def _skipped() -> CheckResult:
    return CheckResult(status="skipped")


async def _read_capped(resp: httpx.Response, cap: int) -> bytes:
    chunks: list[bytes] = []
    size = 0
    async for chunk in resp.aiter_bytes():
        chunks.append(chunk)
        size += len(chunk)
        if size >= cap:
            break
    return b"".join(chunks)[:cap]


_LANG_RE = re.compile(rb"<html[^>]*?\blang\s*=\s*[\"']?([A-Za-z-]+)", re.IGNORECASE)


def _extract_lang(body: bytes) -> str | None:
    # Extraction tolérante par expression régulière : pas de dépendance de parsing
    # pour un seul attribut (spec §6.2).
    match = _LANG_RE.search(body)
    return match.group(1).decode("ascii") if match else None


def suggest_target(host: str, lang_attr: str | None) -> SuggestedTarget:
    tld = host.rsplit(".", 1)[-1]
    country, tld_language = CCTLD.get(tld, ("US", ""))
    language = ""
    if lang_attr:
        primary = lang_attr.split("-", 1)[0].lower()
        if re.fullmatch(r"[a-z]{2}", primary):
            language = primary
    return SuggestedTarget(country=country, language=language or tld_language or "en")


async def _check_dns(host: str) -> CheckResult:
    try:
        addresses = await resolver(host)
    except (OSError, TimeoutError) as exc:
        return CheckResult(status="failed", detail=f"le domaine ne résout pas ({exc})")
    if not addresses:
        return CheckResult(status="failed", detail="le domaine ne résout pas (aucune adresse)")
    if all(not ipaddress.ip_address(addr).is_global for addr in addresses):
        # Garde-fou SSRF minimal (spec §6.1) : pas de fetch vers un hôte privé/réservé.
        return CheckResult(
            status="failed", detail="domaine non public (adresses privées ou réservées)"
        )
    return CheckResult(status="ok")


async def _check_home(
    client: httpx.AsyncClient, host: str
) -> tuple[CheckResult, str | None, str | None]:
    for scheme in ("https", "http"):
        try:
            async with client.stream("GET", f"{scheme}://{host}/") as resp:
                body = await _read_capped(resp, HOME_MAX_BYTES)
        except httpx.TimeoutException:
            return CheckResult(status="failed", detail="délai dépassé"), None, None
        except httpx.ConnectError:
            continue  # repli http si la connexion (TLS incluse) échoue
        except httpx.HTTPError as exc:
            detail = str(exc) or exc.__class__.__name__
            return CheckResult(status="failed", detail=detail), None, None
        status: CheckStatus = "ok" if resp.status_code < 400 else "warning"
        return (
            CheckResult(status=status, detail=f"{resp.status_code} — {resp.url}"),
            resp.url.host,
            _extract_lang(body),
        )
    return CheckResult(status="failed", detail="connexion impossible (https puis http)"), None, None


async def _check_robots(client: httpx.AsyncClient, host: str) -> tuple[CheckResult, list[str]]:
    try:
        async with client.stream("GET", f"https://{host}/robots.txt") as resp:
            body = await _read_capped(resp, ROBOTS_MAX_BYTES)
    except httpx.TimeoutException:
        return CheckResult(status="failed", detail="délai dépassé"), []
    except httpx.HTTPError as exc:
        return CheckResult(status="failed", detail=str(exc) or exc.__class__.__name__), []
    if resp.status_code != 200:
        return CheckResult(status="warning", detail=f"absent ({resp.status_code})"), []
    # Parsing ligne à ligne, pas de lib (spec §6.3).
    directives = [
        line.split(":", 1)[1].strip()
        for line in body.decode("utf-8", errors="replace").splitlines()
        if line.lower().startswith("sitemap:") and line.split(":", 1)[1].strip()
    ]
    plural = "s" if len(directives) > 1 else ""
    detail = f"trouvé ({len(directives)} directive{plural} Sitemap)"
    return CheckResult(status="ok", detail=detail), directives


async def _check_sitemap(client: httpx.AsyncClient, host: str, urls: list[str]) -> CheckResult:
    # Travail borné : seule la première directive est vérifiée, sinon /sitemap.xml.
    url = urls[0] if urls else f"https://{host}/sitemap.xml"
    try:
        async with client.stream("GET", url) as resp:
            await _read_capped(resp, 1024)  # statut + type de contenu, corps non parsé
    except httpx.TimeoutException:
        return CheckResult(status="failed", detail="délai dépassé")
    except httpx.HTTPError:
        return CheckResult(status="warning", detail="aucun sitemap joignable")
    if resp.status_code == 200:
        return CheckResult(status="ok", detail=url)
    return CheckResult(status="warning", detail=f"aucun sitemap trouvé ({resp.status_code})")


async def _check_robots_then_sitemap(
    client: httpx.AsyncClient, host: str
) -> tuple[CheckResult, CheckResult]:
    # Le check sitemap dépend des directives du robots.txt : les deux s'enchaînent,
    # en parallèle du check de la page d'accueil.
    robots, sitemap_urls = await _check_robots(client, host)
    sitemap = await _check_sitemap(client, host, sitemap_urls)
    return robots, sitemap


async def run_preflight(host: str) -> PreflightReport:
    dns = await _check_dns(host)
    if dns.status != "ok":
        return PreflightReport(
            domain=host,
            checks=PreflightChecks(
                dns=dns, home=_skipped(), robots=_skipped(), sitemap=_skipped()
            ),
            final_host=None,
            suggested_target=suggest_target(host, None),
        )
    final_host: str | None = None
    lang: str | None = None
    async with httpx.AsyncClient(
        transport=transport,
        follow_redirects=True,
        max_redirects=MAX_REDIRECTS,
        timeout=FETCH_TIMEOUT,
        headers={"User-Agent": USER_AGENT},
    ) as client:
        try:
            async with asyncio.timeout(TOTAL_BUDGET):
                (home, final_host, lang), (robots, sitemap) = await asyncio.gather(
                    _check_home(client, host), _check_robots_then_sitemap(client, host)
                )
        except TimeoutError:
            # Un dépassement du budget global n'est jamais un 5xx de l'API.
            timed_out = CheckResult(status="failed", detail="délai dépassé (budget global 8 s)")
            home = robots = sitemap = timed_out
    return PreflightReport(
        domain=host,
        checks=PreflightChecks(dns=dns, home=home, robots=robots, sitemap=sitemap),
        final_host=final_host if final_host and final_host != host else None,
        suggested_target=suggest_target(host, lang),
    )
```

- [ ] **Step 4 : Route (GREEN, 2/2)** — dans `api/app/sites/schemas.py`, ajouter à la fin :

```python
class PreflightIn(BaseModel):
    domain: Annotated[str, StringConstraints(min_length=1, max_length=300)]
```

Dans `api/app/sites/router.py` : compléter les imports (`from app.core.errors import AppError`, `from app.sites.domain import DomainError, normalize_domain`, `from app.sites.preflight import PreflightReport, run_preflight`, et `PreflightIn` dans l'import des schémas), puis insérer **entre** `create_site` et `list_sites` :

```python
@router.post("/preflight")
async def preflight(user: CurrentOrgDep, payload: PreflightIn) -> PreflightReport:
    try:
        host = normalize_domain(payload.domain)
    except DomainError as exc:
        raise AppError(422, "Validation error", str(exc)) from exc
    return await run_preflight(host)
```

Run : `uv run pytest -q -W error` — Expected : **97 passed** (88 + 9). Aucun accès réseau réel : les 9 tests passent avec le sandbox réseau fermé.

- [ ] **Step 5 : Lint et commit**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api && uvx ruff check . && uvx ruff format --check .
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): preflight de domaine (DNS, home, robots, sitemap) avec suggestion de cible" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
```

---

### Task 5 : Seed réel et squash de la migration `sites` (rituel de fin de feature DATA)

**Files:**
- Modify: `api/app/seed.py`, `api/tests/test_seed.py`
- Create: `api/alembic/versions/<hash>_sites.py` — Delete: `api/alembic/versions/*_wip.py`

**Interfaces:**
- Produces : `seed.run(org_id)` crée le site `demo.seoinsight.example` (« Site de démonstration », cible `FR`/`fr` par défaut), idempotent, imprime `SITE_ID=…` ; constantes `DEMO_DOMAIN`, `DEMO_NAME`. `parse_args`/`main` et le garde-fou `assert_local` sont inchangés. L'unique révision `sites` remplace la `wip`.

- [ ] **Step 1 : Tests (RED)** — dans `api/tests/test_seed.py`, remplacer les imports de tête par :

```python
import uuid
from types import SimpleNamespace

import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app import seed
from app.db.engine import get_engine
from app.sites.models import Site
```

et ajouter à la fin du fichier :

```python
async def _demo_sites(org_id: str) -> list[Site]:
    async with AsyncSession(get_engine()) as session:
        sites = list(
            (
                await session.scalars(
                    select(Site).where(Site.org_id == org_id, Site.domain == seed.DEMO_DOMAIN)
                )
            ).all()
        )
        for site in sites:
            await session.refresh(site, ["targets"])
    return sites


async def test_seed_run_creates_demo_site() -> None:
    org_id = f"org_{uuid.uuid4().hex[:12]}"
    await seed.run(org_id)
    sites = await _demo_sites(org_id)
    assert len(sites) == 1
    assert sites[0].name == seed.DEMO_NAME
    targets = [(t.country, t.language, t.is_default) for t in sites[0].targets]
    assert targets == [("FR", "fr", True)]


async def test_seed_run_is_idempotent(capsys: pytest.CaptureFixture[str]) -> None:
    org_id = f"org_{uuid.uuid4().hex[:12]}"
    await seed.run(org_id)
    await seed.run(org_id)
    assert len(await _demo_sites(org_id)) == 1
    assert "déjà présent" in capsys.readouterr().out
```

Run : `uv run pytest -q -W error tests/test_seed.py` — Expected : les 2 nouveaux tests échouent (`run` est encore un no-op, aucun site créé) ; les 3 existants passent.

- [ ] **Step 2 : `api/app/seed.py` (GREEN)** — remplacer le docstring, les imports et `run` (le reste — `parse_args`, `main`, `__main__` — est inchangé) :

```python
"""Données de démonstration de la base DATA.

`run(org_id)` crée le site de démonstration du module Sites pour l'organisation « Dev »
(id imprimé par `bun run db:seed` côté web/ — rien n'est partagé entre les bases).
Idempotent : relançable à volonté. Le TLD `.example` (RFC 2606) ne résout jamais :
domaine fictif garanti, et le seed n'appelle pas le preflight — il écrit en base,
sous le garde-fou `assert_local`.

Usage : uv run python -m app.seed --org-id <id>
"""

import argparse
import asyncio
import sys

from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

from app.core.config import get_settings
from app.db.guard import assert_local
from app.sites.models import Site, SiteTarget

DEMO_DOMAIN = "demo.seoinsight.example"
DEMO_NAME = "Site de démonstration"


async def run(org_id: str) -> None:
    engine = create_async_engine(str(get_settings().database_url))
    try:
        async with async_sessionmaker(engine, expire_on_commit=False)() as session:
            existing = await session.scalar(
                select(Site.id).where(Site.org_id == org_id, Site.domain == DEMO_DOMAIN)
            )
            if existing is not None:
                print(
                    f"seed DATA : site de démo déjà présent pour l'organisation {org_id} — "
                    f"SITE_ID={existing}"
                )
                return
            site = Site(org_id=org_id, domain=DEMO_DOMAIN, name=DEMO_NAME)
            site.targets = [
                SiteTarget(org_id=org_id, country="FR", language="fr", is_default=True)
            ]
            session.add(site)
            await session.commit()
            print(
                f"seed DATA : site de démo créé pour l'organisation {org_id} — SITE_ID={site.id}"
            )
    finally:
        await engine.dispose()
```

Run : `uv run pytest -q -W error` — Expected : **99 passed** (97 + 2 ; `test_seed_run_names_the_org` reste vert : les deux branches impriment l'org).

- [ ] **Step 3 : Squash de la migration (rituel CLAUDE.md, DATA)** — les modèles n'ont plus bougé depuis la Task 2 :

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api
uv run alembic downgrade b86e39e2c13b
DATABASE_URL=postgresql+asyncpg://seoinsight:seoinsight@localhost:5432/data_test uv run alembic downgrade b86e39e2c13b
rm alembic/versions/*_wip.py
uv run alembic revision --autogenerate -m "sites"
```

Expected : les deux bases redescendent sur `b86e39e2c13b` (init), puis **une** révision `*_sites.py` au-dessus de `b86e39e2c13b` recrée tout.

- [ ] **Step 4 : Relecture du SQL généré (obligatoire, spec architecture §8)**

```bash
uv run alembic upgrade b86e39e2c13b:head --sql
```

Vérifier dans la sortie : `CREATE TABLE sites` et `CREATE TABLE site_targets` uniquement ; `DEFAULT uuidv7()` sur les deux `id` ; `UNIQUE (org_id, domain)` ; FK `REFERENCES sites (id) ON DELETE CASCADE` ; les deux `CHECK` ISO ; `UNIQUE (site_id, country, language)` ; `CREATE UNIQUE INDEX uq_site_targets_default ON site_targets (site_id) WHERE is_default` ; les index `org_id`/`site_id` ; **aucun `DROP`**. Le `downgrade()` de la révision supprime les deux tables (cibles d'abord). Puis appliquer et prouver que la chaîne rejoue de zéro :

```bash
uv run alembic upgrade head
uv run pytest -q -W error
```

Expected : upgrade OK sur `data` ; **99 passed** (la fixture rejoue la chaîne sur `data_test`, `alembic check` du test de migrations reste vert).

- [ ] **Step 5 : Seed de bout en bout (utilisateur ou exécutant — exige les deux apps DB seulement)** — l'`ORG_ID` vient **toujours** de la sortie du seed web, jamais codé en dur :

```bash
cd /Users/humperdoo/Github/seoinsight-v2/web && bun run db:seed | tail -n 1   # → ORG_ID=<id>
cd ../api && uv run python -m app.seed --org-id <id>
uv run python -m app.seed --org-id <id>
uv run python -m app.db_reset --org-id <id>
```

Expected : 1er appel → `seed DATA : site de démo créé … SITE_ID=…` ; 2e → `déjà présent` avec le **même** `SITE_ID` ; `db_reset` → drop, `Running upgrade  -> b86e39e2c13b` puis `-> <hash sites>`, et le seed recrée le site de démo.

- [ ] **Step 6 : Lint et commit**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/api && uvx ruff check . && uvx ruff format --check .
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(api): seed du site de démonstration et migration sites définitive (squash des wip)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
```

---

### Task 6 : `web/` — frontière Zod, `formatDomain`, `locales` (logique pure + vitest)

**Files:**
- Create: `web/lib/sites.ts`, `web/lib/sites.test.ts`, `web/lib/format-domain.ts`, `web/lib/format-domain.test.ts`, `web/lib/locales.ts`, `web/lib/locales.test.ts`

**Interfaces:**
- Produces : `targetSchema`, `siteSchema`, `siteListSchema`, `checkResultSchema`, `preflightReportSchema` + types inférés `Target`, `Site`, `SiteList`, `PreflightReport` ; `formatDomain(ascii: string): string` (punycode → Unicode, repli sur l'entrée) ; `COUNTRIES` (30), `LANGUAGES` (20), `formatTarget({country, language})` → `"France · français"`. Aucune dépendance nouvelle (`domainToUnicode` vient de `node:url`).

- [ ] **Step 1 : Tests (RED)** — `web/lib/format-domain.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { formatDomain } from "./format-domain";

describe("formatDomain", () => {
  it("convertit le punycode en Unicode", () => {
    expect(formatDomain("xn--chteau-xta.fr")).toBe("château.fr");
    expect(formatDomain("xn--bcher-kva.example")).toBe("bücher.example");
  });
  it("laisse l'ASCII inchangé et retombe sur l'entrée si la conversion échoue", () => {
    expect(formatDomain("www.exemple.fr")).toBe("www.exemple.fr");
    expect(formatDomain("")).toBe("");
  });
});
```

`web/lib/locales.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { formatTarget } from "./locales";

describe("formatTarget", () => {
  it("affiche les libellés français", () => {
    expect(formatTarget({ country: "FR", language: "fr" })).toBe("France · français");
  });
  it("retombe sur le code quand il est inconnu", () => {
    expect(formatTarget({ country: "ZZ", language: "zz" })).toBe("ZZ · zz");
  });
});
```

`web/lib/sites.test.ts` :

```ts
import { describe, expect, it } from "vitest";
import { preflightReportSchema, siteListSchema } from "./sites";

const target = {
  id: "0191d5e8-0000-7000-8000-000000000001",
  country: "FR",
  language: "fr",
  is_default: true,
  created_at: "2026-08-31T10:00:00Z",
};

const site = {
  id: "0191d5e8-0000-7000-8000-000000000002",
  domain: "www.exemple.fr",
  name: "Exemple",
  created_at: "2026-08-31T10:00:00Z",
  updated_at: "2026-08-31T10:00:00Z",
  targets: [target],
};

describe("schémas du module Sites", () => {
  it("parse une liste de sites", () => {
    const parsed = siteListSchema.parse({ items: [site], next_cursor: null });
    expect(parsed.items[0]?.domain).toBe("www.exemple.fr");
    expect(parsed.next_cursor).toBeNull();
  });
  it("parse un rapport de preflight", () => {
    const report = preflightReportSchema.parse({
      domain: "exemple.fr",
      checks: {
        dns: { status: "ok", detail: null },
        home: { status: "ok", detail: "200 — https://www.exemple.fr/" },
        robots: { status: "warning", detail: "absent (404)" },
        sitemap: { status: "skipped", detail: null },
      },
      final_host: "www.exemple.fr",
      suggested_target: { country: "FR", language: "fr" },
    });
    expect(report.suggested_target.language).toBe("fr");
  });
  it("rejette une réponse malformée", () => {
    expect(() =>
      siteListSchema.parse({ items: [{ id: "pas-un-uuid" }], next_cursor: null }),
    ).toThrow();
  });
});
```

Run : `cd web && bun run test` — Expected : FAIL (modules `./sites`, `./format-domain`, `./locales` introuvables).

- [ ] **Step 2 : `web/lib/sites.ts` (GREEN, 1/3)** — toute réponse d'`apiFetch` du module traverse ces schémas (« toute donnée traversant une frontière passe par Zod ») :

```ts
import * as z from "zod";

export const targetSchema = z.object({
  id: z.uuid(),
  country: z.string(),
  language: z.string(),
  is_default: z.boolean(),
  created_at: z.string(),
});

export const siteSchema = z.object({
  id: z.uuid(),
  domain: z.string(),
  name: z.string(),
  created_at: z.string(),
  updated_at: z.string(),
  targets: z.array(targetSchema),
});

export const siteListSchema = z.object({
  items: z.array(siteSchema),
  next_cursor: z.string().nullable(),
});

export const checkResultSchema = z.object({
  status: z.enum(["ok", "warning", "failed", "skipped"]),
  detail: z.string().nullable(),
});

export const preflightReportSchema = z.object({
  domain: z.string(),
  checks: z.object({
    dns: checkResultSchema,
    home: checkResultSchema,
    robots: checkResultSchema,
    sitemap: checkResultSchema,
  }),
  final_host: z.string().nullable(),
  suggested_target: z.object({ country: z.string(), language: z.string() }),
});

export type Target = z.infer<typeof targetSchema>;
export type Site = z.infer<typeof siteSchema>;
export type SiteList = z.infer<typeof siteListSchema>;
export type PreflightReport = z.infer<typeof preflightReportSchema>;
```

- [ ] **Step 3 : `web/lib/format-domain.ts` (GREEN, 2/3)** — server-only de fait (appelée dans des Server Components) :

```ts
import { domainToUnicode } from "node:url";

/** Punycode → Unicode pour l'affichage ; la forme stockée reste l'ASCII. */
export function formatDomain(ascii: string): string {
  const unicode = domainToUnicode(ascii);
  return unicode || ascii;
}
```

- [ ] **Step 4 : `web/lib/locales.ts` (GREEN, 3/3)** — sous-ensemble des marchés de l'agence, extensible ; libellés français en dur :

```ts
export type Locale = { code: string; label: string };

// ISO 3166-1 alpha-2.
export const COUNTRIES: Locale[] = [
  { code: "FR", label: "France" },
  { code: "BE", label: "Belgique" },
  { code: "CH", label: "Suisse" },
  { code: "LU", label: "Luxembourg" },
  { code: "MC", label: "Monaco" },
  { code: "DE", label: "Allemagne" },
  { code: "AT", label: "Autriche" },
  { code: "GB", label: "Royaume-Uni" },
  { code: "IE", label: "Irlande" },
  { code: "ES", label: "Espagne" },
  { code: "PT", label: "Portugal" },
  { code: "IT", label: "Italie" },
  { code: "NL", label: "Pays-Bas" },
  { code: "PL", label: "Pologne" },
  { code: "CZ", label: "Tchéquie" },
  { code: "SE", label: "Suède" },
  { code: "NO", label: "Norvège" },
  { code: "DK", label: "Danemark" },
  { code: "FI", label: "Finlande" },
  { code: "GR", label: "Grèce" },
  { code: "RO", label: "Roumanie" },
  { code: "TR", label: "Turquie" },
  { code: "US", label: "États-Unis" },
  { code: "CA", label: "Canada" },
  { code: "MX", label: "Mexique" },
  { code: "BR", label: "Brésil" },
  { code: "MA", label: "Maroc" },
  { code: "TN", label: "Tunisie" },
  { code: "DZ", label: "Algérie" },
  { code: "SN", label: "Sénégal" },
];

// ISO 639-1.
export const LANGUAGES: Locale[] = [
  { code: "fr", label: "français" },
  { code: "en", label: "anglais" },
  { code: "de", label: "allemand" },
  { code: "es", label: "espagnol" },
  { code: "it", label: "italien" },
  { code: "pt", label: "portugais" },
  { code: "nl", label: "néerlandais" },
  { code: "pl", label: "polonais" },
  { code: "cs", label: "tchèque" },
  { code: "sv", label: "suédois" },
  { code: "no", label: "norvégien" },
  { code: "da", label: "danois" },
  { code: "fi", label: "finnois" },
  { code: "el", label: "grec" },
  { code: "ro", label: "roumain" },
  { code: "tr", label: "turc" },
  { code: "ar", label: "arabe" },
  { code: "ru", label: "russe" },
  { code: "ja", label: "japonais" },
  { code: "zh", label: "chinois" },
];

export function formatTarget(target: { country: string; language: string }): string {
  const country = COUNTRIES.find((c) => c.code === target.country)?.label ?? target.country;
  const language = LANGUAGES.find((l) => l.code === target.language)?.label ?? target.language;
  return `${country} · ${language}`;
}
```

Run : `bun run test` — Expected : **15 passed** (8 existants + 7).

- [ ] **Step 5 : Lint, format, commit**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/web && bun run lint && bun run format:check && bunx tsc --noEmit
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(web): frontière Zod du module Sites, formatDomain et locales" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
```

---

### Task 7 : `web/` — wizard, liste, page du site, navigation, qualité, merge

**Files:**
- Create: `web/app/(app)/sites/{page,loading,error,not-found}.tsx`, `web/app/(app)/sites/new/{page,actions.ts,loading,error}.tsx`, `web/app/(app)/sites/[siteId]/{page,actions.ts,loading,error,not-found}.tsx`, `web/components/sites/site-wizard.tsx`, `web/components/sites/rename-site-form.tsx`, `web/components/sites/targets-manager.tsx`, `web/components/sites/delete-site-form.tsx`
- Modify: `web/lib/api.ts`, `web/components/app/header.tsx`

**Interfaces:**
- Consumes : `apiFetch`, `ApiError`, `requireOrg`, schémas et helpers de la Task 6, composants `ui/` existants (`Button`, `Card*`, `Input`, `Label`).
- Produces : `preflightAction`, `createSiteAction` (échec typé `{ error, status? }`, succès = `redirect`) ; `renameSiteAction`, `addTargetAction`, `promoteTargetAction`, `deleteTargetAction` (→ `{ ok: true } | { error }`), `deleteSiteAction` (confirmation par saisie du domaine, → `redirect("/sites")`). Pas d'appel `api/` depuis le navigateur : tout passe par ces actions et les Server Components.

- [ ] **Step 1 : `apiFetch` et les 204** — dans `web/lib/api.ts`, remplacer la dernière ligne de `apiFetch` :

```ts
  if (!res.ok) throw await parseProblem(res);
  // DELETE → 204 sans corps : res.json() lèverait une SyntaxError.
  if (res.status === 204) return undefined as T;
  return (await res.json()) as T;
```

- [ ] **Step 2 : `web/app/(app)/sites/new/actions.ts`**

```ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import * as z from "zod";
import { apiFetch } from "@/lib/api";
import { ApiError } from "@/lib/api-error";
import { requireOrg } from "@/lib/org";
import { preflightReportSchema, siteSchema, type PreflightReport } from "@/lib/sites";

export type ActionFailure = { error: string; status?: number };

const JSON_HEADERS = { "content-type": "application/json" };

const domainInput = z.object({
  domain: z.string().trim().min(1, "Saisir un domaine").max(300),
});

const createInput = z
  .object({
    domain: z.string().trim().min(1).max(300),
    targets: z
      .array(
        z.object({
          country: z.string().regex(/^[A-Z]{2}$/),
          language: z.string().regex(/^[a-z]{2}$/),
          is_default: z.boolean(),
        }),
      )
      .min(1)
      .max(20),
  })
  .refine((value) => value.targets.filter((t) => t.is_default).length === 1, {
    message: "Exactement une cible doit être marquée par défaut",
  });

// Relaye le Problem Details de l'api ; toute autre erreur remonte au error.tsx —
// jamais d'erreur brute du serveur au navigateur (architecture §9).
function relay(error: unknown): ActionFailure {
  if (error instanceof ApiError) {
    return { error: error.detail ?? error.title, status: error.status };
  }
  throw error;
}

export async function preflightAction(
  input: unknown,
): Promise<{ report: PreflightReport } | ActionFailure> {
  await requireOrg();
  const parsed = domainInput.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Domaine invalide", status: 422 };
  }
  try {
    const raw = await apiFetch<unknown>("/sites/preflight", {
      method: "POST",
      headers: JSON_HEADERS,
      body: JSON.stringify(parsed.data),
    });
    return { report: preflightReportSchema.parse(raw) };
  } catch (error) {
    return relay(error);
  }
}

export async function createSiteAction(input: unknown): Promise<ActionFailure> {
  await requireOrg();
  const parsed = createInput.safeParse(input);
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Formulaire invalide", status: 422 };
  }
  let siteId: string;
  try {
    const raw = await apiFetch<unknown>("/sites", {
      method: "POST",
      headers: JSON_HEADERS,
      body: JSON.stringify(parsed.data),
    });
    siteId = siteSchema.parse(raw).id;
  } catch (error) {
    return relay(error);
  }
  revalidatePath("/sites");
  redirect(`/sites/${siteId}`);
}
```

- [ ] **Step 3 : `web/app/(app)/sites/[siteId]/actions.ts`**

```ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import * as z from "zod";
import { apiFetch } from "@/lib/api";
import { ApiError } from "@/lib/api-error";
import { formatDomain } from "@/lib/format-domain";
import { requireOrg } from "@/lib/org";

export type ActionResult = { ok: true } | { error: string };

const JSON_HEADERS = { "content-type": "application/json" };
const uuid = z.uuid();

function relay(error: unknown): { error: string } {
  if (error instanceof ApiError) return { error: error.detail ?? error.title };
  throw error;
}

function refresh(siteId: string): void {
  revalidatePath("/sites");
  revalidatePath(`/sites/${siteId}`);
}

export async function renameSiteAction(input: unknown): Promise<ActionResult> {
  await requireOrg();
  const parsed = z
    .object({ siteId: uuid, name: z.string().trim().min(1).max(200) })
    .safeParse(input);
  if (!parsed.success) return { error: "Nom invalide" };
  try {
    await apiFetch<unknown>(`/sites/${parsed.data.siteId}`, {
      method: "PATCH",
      headers: JSON_HEADERS,
      body: JSON.stringify({ name: parsed.data.name }),
    });
  } catch (error) {
    return relay(error);
  }
  refresh(parsed.data.siteId);
  return { ok: true };
}

export async function deleteSiteAction(input: unknown): Promise<{ error: string }> {
  await requireOrg();
  const parsed = z
    .object({ siteId: uuid, domain: z.string().min(1), confirm: z.string() })
    .safeParse(input);
  if (!parsed.success) return { error: "Requête invalide" };
  const { siteId, domain, confirm } = parsed.data;
  // Confirmation forte (décision §10.2) : la saisie doit citer le domaine (forme
  // ASCII stockée ou forme Unicode affichée). Garde-fou UX, pas de sécurité :
  // l'api ne supprime de toute façon que les sites de l'organisation du JWT.
  if (confirm.trim() !== domain && confirm.trim() !== formatDomain(domain)) {
    return { error: "Le domaine saisi ne correspond pas" };
  }
  try {
    await apiFetch<undefined>(`/sites/${siteId}`, { method: "DELETE" });
  } catch (error) {
    return relay(error);
  }
  revalidatePath("/sites");
  redirect("/sites");
}

export async function addTargetAction(input: unknown): Promise<ActionResult> {
  await requireOrg();
  const parsed = z
    .object({
      siteId: uuid,
      country: z.string().regex(/^[A-Z]{2}$/),
      language: z.string().regex(/^[a-z]{2}$/),
    })
    .safeParse(input);
  if (!parsed.success) return { error: "Cible invalide" };
  try {
    await apiFetch<unknown>(`/sites/${parsed.data.siteId}/targets`, {
      method: "POST",
      headers: JSON_HEADERS,
      body: JSON.stringify({ country: parsed.data.country, language: parsed.data.language }),
    });
  } catch (error) {
    return relay(error);
  }
  refresh(parsed.data.siteId);
  return { ok: true };
}

export async function promoteTargetAction(input: unknown): Promise<ActionResult> {
  await requireOrg();
  const parsed = z.object({ siteId: uuid, targetId: uuid }).safeParse(input);
  if (!parsed.success) return { error: "Requête invalide" };
  try {
    await apiFetch<unknown>(`/sites/${parsed.data.siteId}/targets/${parsed.data.targetId}`, {
      method: "PATCH",
      headers: JSON_HEADERS,
      body: JSON.stringify({ is_default: true }),
    });
  } catch (error) {
    return relay(error);
  }
  refresh(parsed.data.siteId);
  return { ok: true };
}

export async function deleteTargetAction(input: unknown): Promise<ActionResult> {
  await requireOrg();
  const parsed = z.object({ siteId: uuid, targetId: uuid }).safeParse(input);
  if (!parsed.success) return { error: "Requête invalide" };
  try {
    await apiFetch<undefined>(
      `/sites/${parsed.data.siteId}/targets/${parsed.data.targetId}`,
      { method: "DELETE" },
    );
  } catch (error) {
    return relay(error);
  }
  refresh(parsed.data.siteId);
  return { ok: true };
}
```

- [ ] **Step 4 : `web/components/sites/site-wizard.tsx`** — les 3 écrans dans un composant client, rien n'est persisté avant l'écran 3 (décision §2) :

```tsx
"use client";

import { useRef, useState, useTransition } from "react";
import { createSiteAction, preflightAction } from "@/app/(app)/sites/new/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { COUNTRIES, LANGUAGES } from "@/lib/locales";
import type { PreflightReport } from "@/lib/sites";

type TargetRow = { key: number; country: string; language: string; is_default: boolean };

const STATUS_LABELS: Record<string, string> = {
  ok: "OK",
  warning: "Avertissement",
  failed: "Échec",
  skipped: "Ignoré",
};

const CHECK_LABELS = {
  dns: "DNS",
  home: "Page d'accueil",
  robots: "robots.txt",
  sitemap: "Sitemap",
} as const;

const STEPS = ["Domaine", "Cibles", "Lancement"];

const selectClass = "h-9 rounded-md border bg-transparent px-2 text-sm";

export function SiteWizard() {
  const [step, setStep] = useState<1 | 2 | 3>(1);
  const [domain, setDomain] = useState("");
  const [report, setReport] = useState<PreflightReport | null>(null);
  const [invalidDomain, setInvalidDomain] = useState(false);
  const [useFinalHost, setUseFinalHost] = useState(true);
  const [targets, setTargets] = useState<TargetRow[]>([
    { key: 0, country: "FR", language: "fr", is_default: true },
  ]);
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();
  // Clés stables et déterministes (pas de crypto.randomUUID : mismatch SSR/hydratation).
  const nextKey = useRef(1);

  const finalHost = report?.final_host ?? null;
  const effectiveDomain = finalHost && useFinalHost ? finalHost : domain.trim();

  const verify = () => {
    setError(null);
    startTransition(async () => {
      const result = await preflightAction({ domain });
      if ("error" in result) {
        setReport(null);
        // Seul un domaine syntaxiquement invalide (422) arrête l'écran 1 (spec §2).
        setInvalidDomain(result.status === 422);
        setError(result.error);
        return;
      }
      setInvalidDomain(false);
      setUseFinalHost(true); // bascule vers final_host pré-cochée (décision §10.4)
      setReport(result.report);
      setTargets([
        { key: nextKey.current++, ...result.report.suggested_target, is_default: true },
      ]);
    });
  };

  const updateTarget = (key: number, patch: Partial<TargetRow>) => {
    setTargets((rows) => rows.map((row) => (row.key === key ? { ...row, ...patch } : row)));
  };

  const create = () => {
    setError(null);
    startTransition(async () => {
      // Succès = redirect('/sites/{id}') côté serveur : on ne revient ici qu'en erreur.
      const result = await createSiteAction({
        domain: effectiveDomain,
        targets: targets.map(({ country, language, is_default }) => ({
          country,
          language,
          is_default,
        })),
      });
      setError(result.error);
      // 409 (domaine déjà suivi) ou 422 : l'erreur se corrige à l'écran 1.
      if (result.status === 409 || result.status === 422) setStep(1);
    });
  };

  return (
    <div className="space-y-6">
      <ol className="text-muted-foreground flex gap-4 text-sm">
        {STEPS.map((label, index) => (
          <li key={label} className={step === index + 1 ? "text-foreground font-medium" : ""}>
            {index + 1}. {label}
          </li>
        ))}
      </ol>

      {step === 1 ? (
        <div className="space-y-4">
          <div className="max-w-md space-y-1">
            <Label htmlFor="wizard-domain">Domaine ou URL du site</Label>
            <Input
              id="wizard-domain"
              placeholder="https://exemple.fr"
              value={domain}
              onChange={(event) => {
                setDomain(event.target.value);
                setInvalidDomain(false);
                setReport(null);
              }}
            />
          </div>
          <div className="flex gap-2">
            <Button
              type="button"
              variant="outline"
              disabled={pending || domain.trim().length === 0}
              onClick={verify}
            >
              Vérifier
            </Button>
            <Button
              type="button"
              disabled={pending || domain.trim().length === 0 || invalidDomain}
              onClick={() => setStep(2)}
            >
              Continuer
            </Button>
          </div>
          {report ? (
            <div className="max-w-md space-y-2 rounded border p-3 text-sm">
              {(Object.keys(CHECK_LABELS) as (keyof typeof CHECK_LABELS)[]).map((key) => {
                const check = report.checks[key];
                return (
                  <p key={key} className="flex justify-between gap-4">
                    <span>{CHECK_LABELS[key]}</span>
                    <span className={check.status === "failed" ? "text-destructive" : ""}>
                      {STATUS_LABELS[check.status]}
                      {check.detail ? ` — ${check.detail}` : ""}
                    </span>
                  </p>
                );
              })}
              {finalHost ? (
                <label className="flex items-center gap-2">
                  <input
                    type="checkbox"
                    checked={useFinalHost}
                    onChange={(event) => setUseFinalHost(event.target.checked)}
                  />
                  <span>
                    {report.domain} redirige vers {finalHost} : utiliser {finalHost}
                  </span>
                </label>
              ) : null}
            </div>
          ) : null}
        </div>
      ) : null}

      {step === 2 ? (
        <div className="space-y-4">
          <div className="overflow-x-auto">
            <table className="w-full text-sm">
              <thead>
                <tr className="text-muted-foreground border-b text-left">
                  <th className="py-2 pr-4 font-medium">Pays</th>
                  <th className="py-2 pr-4 font-medium">Langue</th>
                  <th className="py-2 pr-4 font-medium">Par défaut</th>
                  <th className="py-2 font-medium" />
                </tr>
              </thead>
              <tbody>
                {targets.map((target, index) => (
                  <tr key={target.key} className="border-b">
                    <td className="py-2 pr-4">
                      <select
                        aria-label={`Pays de la cible ${index + 1}`}
                        className={selectClass}
                        value={target.country}
                        onChange={(event) =>
                          updateTarget(target.key, { country: event.target.value })
                        }
                      >
                        {COUNTRIES.map((c) => (
                          <option key={c.code} value={c.code}>
                            {c.label}
                          </option>
                        ))}
                      </select>
                    </td>
                    <td className="py-2 pr-4">
                      <select
                        aria-label={`Langue de la cible ${index + 1}`}
                        className={selectClass}
                        value={target.language}
                        onChange={(event) =>
                          updateTarget(target.key, { language: event.target.value })
                        }
                      >
                        {LANGUAGES.map((l) => (
                          <option key={l.code} value={l.code}>
                            {l.label}
                          </option>
                        ))}
                      </select>
                    </td>
                    <td className="py-2 pr-4">
                      <input
                        type="radio"
                        name="default-target"
                        aria-label={`Cible par défaut ${index + 1}`}
                        checked={target.is_default}
                        onChange={() =>
                          setTargets((rows) =>
                            rows.map((row) => ({ ...row, is_default: row.key === target.key })),
                          )
                        }
                      />
                    </td>
                    <td className="py-2">
                      <Button
                        type="button"
                        variant="ghost"
                        size="sm"
                        disabled={target.is_default || targets.length === 1}
                        onClick={() =>
                          setTargets((rows) => rows.filter((row) => row.key !== target.key))
                        }
                      >
                        Retirer
                      </Button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          <Button
            type="button"
            variant="outline"
            disabled={targets.length >= 20}
            onClick={() =>
              setTargets((rows) => [
                ...rows,
                { key: nextKey.current++, country: "FR", language: "fr", is_default: false },
              ])
            }
          >
            Ajouter une cible
          </Button>
          <div className="flex gap-2">
            <Button type="button" variant="outline" onClick={() => setStep(1)}>
              Retour
            </Button>
            <Button type="button" onClick={() => setStep(3)}>
              Continuer
            </Button>
          </div>
        </div>
      ) : null}

      {step === 3 ? (
        <div className="space-y-4">
          <div className="max-w-md space-y-2 rounded border p-4 text-sm">
            <p>
              <span className="text-muted-foreground">Domaine :</span> {effectiveDomain}
            </p>
            <p>
              <span className="text-muted-foreground">Cibles :</span>{" "}
              {targets
                .map(
                  (t) => `${t.country} · ${t.language}${t.is_default ? " (défaut)" : ""}`,
                )
                .join(", ")}
            </p>
            <p className="text-muted-foreground">
              Le crawl démarrera ici automatiquement (module Crawl à venir).
            </p>
          </div>
          <div className="flex gap-2">
            <Button type="button" variant="outline" disabled={pending} onClick={() => setStep(2)}>
              Retour
            </Button>
            <Button type="button" disabled={pending} onClick={create}>
              Créer le site
            </Button>
          </div>
        </div>
      ) : null}

      {error ? <p className="text-destructive text-sm">{error}</p> : null}
    </div>
  );
}
```

Note : `createSiteAction` retourne `ActionFailure` avec `status?` — le wizard lit `result.status`, jamais le texte de l'erreur, pour décider du retour à l'écran 1. `preflightAction` porte aussi `status` (`ActionFailure`), utilisé pour ne bloquer « Continuer » que sur un 422.

- [ ] **Step 5 : Feuilles interactives de la page du site**

`web/components/sites/rename-site-form.tsx` :

```tsx
"use client";

import { useState, useTransition } from "react";
import { renameSiteAction } from "@/app/(app)/sites/[siteId]/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function RenameSiteForm({ siteId, name }: { siteId: string; name: string }) {
  const [value, setValue] = useState(name);
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  return (
    <form
      className="flex max-w-md items-end gap-2"
      onSubmit={(event) => {
        event.preventDefault();
        setError(null);
        startTransition(async () => {
          const result = await renameSiteAction({ siteId, name: value });
          if ("error" in result) setError(result.error);
        });
      }}
    >
      <div className="flex-1 space-y-1">
        <Label htmlFor="site-name">Nom du site</Label>
        <Input id="site-name" value={value} onChange={(e) => setValue(e.target.value)} />
        {error ? <p className="text-destructive text-sm">{error}</p> : null}
      </div>
      <Button type="submit" disabled={pending || value.trim().length === 0}>
        Renommer
      </Button>
    </form>
  );
}
```

`web/components/sites/targets-manager.tsx` :

```tsx
"use client";

import { useState, useTransition } from "react";
import {
  addTargetAction,
  deleteTargetAction,
  promoteTargetAction,
  type ActionResult,
} from "@/app/(app)/sites/[siteId]/actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { COUNTRIES, LANGUAGES, formatTarget } from "@/lib/locales";
import type { Target } from "@/lib/sites";

const selectClass = "h-9 rounded-md border bg-transparent px-2 text-sm";

export function TargetsManager({ siteId, targets }: { siteId: string; targets: Target[] }) {
  const [country, setCountry] = useState("FR");
  const [language, setLanguage] = useState("fr");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  const run = (action: () => Promise<ActionResult>) => {
    setError(null);
    startTransition(async () => {
      const result = await action();
      if ("error" in result) setError(result.error);
    });
  };

  return (
    <div className="max-w-md space-y-3">
      <h2 className="font-medium">Cibles (pays · langue)</h2>
      <ul className="space-y-2">
        {targets.map((target) => (
          <li
            key={target.id}
            className="flex items-center justify-between gap-2 rounded border px-3 py-2 text-sm"
          >
            <span>
              {formatTarget(target)}
              {target.is_default ? " — par défaut" : ""}
            </span>
            {target.is_default ? null : (
              <span className="flex gap-2">
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  disabled={pending}
                  onClick={() => run(() => promoteTargetAction({ siteId, targetId: target.id }))}
                >
                  Par défaut
                </Button>
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  disabled={pending}
                  onClick={() => run(() => deleteTargetAction({ siteId, targetId: target.id }))}
                >
                  Supprimer
                </Button>
              </span>
            )}
          </li>
        ))}
      </ul>
      <form
        className="flex items-end gap-2"
        onSubmit={(event) => {
          event.preventDefault();
          run(() => addTargetAction({ siteId, country, language }));
        }}
      >
        <div className="space-y-1">
          <Label htmlFor="new-country">Pays</Label>
          <select
            id="new-country"
            className={selectClass}
            value={country}
            onChange={(e) => setCountry(e.target.value)}
          >
            {COUNTRIES.map((c) => (
              <option key={c.code} value={c.code}>
                {c.label}
              </option>
            ))}
          </select>
        </div>
        <div className="space-y-1">
          <Label htmlFor="new-language">Langue</Label>
          <select
            id="new-language"
            className={selectClass}
            value={language}
            onChange={(e) => setLanguage(e.target.value)}
          >
            {LANGUAGES.map((l) => (
              <option key={l.code} value={l.code}>
                {l.label}
              </option>
            ))}
          </select>
        </div>
        <Button type="submit" disabled={pending}>
          Ajouter
        </Button>
      </form>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
    </div>
  );
}
```

`web/components/sites/delete-site-form.tsx` :

```tsx
"use client";

import { useState, useTransition } from "react";
import { deleteSiteAction } from "@/app/(app)/sites/[siteId]/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function DeleteSiteForm({
  siteId,
  domain,
  displayDomain,
}: {
  siteId: string;
  domain: string;
  displayDomain: string;
}) {
  const [confirm, setConfirm] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();
  // Forme Unicode affichée ou forme ASCII stockée : les deux valent confirmation.
  const confirmed = confirm.trim() === domain || confirm.trim() === displayDomain;

  return (
    <form
      className="border-destructive/40 max-w-md space-y-2 rounded border p-4"
      onSubmit={(event) => {
        event.preventDefault();
        setError(null);
        startTransition(async () => {
          // Succès = redirect('/sites') côté serveur : on ne revient ici qu'en erreur.
          const result = await deleteSiteAction({ siteId, domain, confirm });
          setError(result.error);
        });
      }}
    >
      <h2 className="font-medium">Supprimer le site</h2>
      <p className="text-muted-foreground text-sm">
        Supprime le site, ses cibles et toutes les données rattachées (irréversible). Saisir{" "}
        <span className="font-mono">{displayDomain}</span> pour confirmer.
      </p>
      <div className="space-y-1">
        <Label htmlFor="confirm-domain">Domaine</Label>
        <Input
          id="confirm-domain"
          value={confirm}
          onChange={(e) => setConfirm(e.target.value)}
        />
      </div>
      <Button type="submit" variant="destructive" disabled={pending || !confirmed}>
        Supprimer définitivement
      </Button>
      {error ? <p className="text-destructive text-sm">{error}</p> : null}
    </form>
  );
}
```

- [ ] **Step 6 : Pages serveur et fichiers de segment**

`web/app/(app)/sites/page.tsx` :

```tsx
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { formatDomain } from "@/lib/format-domain";
import { requireOrg } from "@/lib/org";
import { siteListSchema } from "@/lib/sites";

export const metadata = { title: "Sites — seoinsight" };

export default async function SitesPage({
  searchParams,
}: {
  searchParams: Promise<{ cursor?: string }>;
}) {
  await requireOrg();
  const { cursor } = await searchParams;
  const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
  const { items, next_cursor } = siteListSchema.parse(await apiFetch<unknown>(`/sites${query}`));

  if (items.length === 0 && !cursor) {
    return (
      <section className="space-y-4">
        <h1 className="text-2xl font-semibold">Sites</h1>
        <p className="text-muted-foreground">Aucun site pour l'instant.</p>
        <Link href="/sites/new" className="underline">
          Ajouter un site
        </Link>
      </section>
    );
  }

  return (
    <section className="space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-semibold">Sites</h1>
        <Link href="/sites/new" className="underline">
          Ajouter un site
        </Link>
      </div>
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead>
            <tr className="text-muted-foreground border-b text-left">
              <th className="py-2 pr-4 font-medium">Domaine</th>
              <th className="py-2 pr-4 font-medium">Nom</th>
              <th className="py-2 pr-4 font-medium">Cibles</th>
              <th className="py-2 font-medium">Créé le</th>
            </tr>
          </thead>
          <tbody>
            {items.map((site) => (
              <tr key={site.id} className="border-b">
                <td className="py-2 pr-4">
                  <Link href={`/sites/${site.id}`} className="font-medium underline">
                    {formatDomain(site.domain)}
                  </Link>
                </td>
                <td className="py-2 pr-4">{site.name}</td>
                <td className="py-2 pr-4">
                  <span className="flex flex-wrap gap-1">
                    {site.targets.map((target) => (
                      <span key={target.id} className="rounded border px-1.5 py-0.5 text-xs">
                        {target.country} · {target.language}
                        {target.is_default ? " ★" : ""}
                      </span>
                    ))}
                  </span>
                </td>
                <td className="py-2">{new Date(site.created_at).toLocaleDateString("fr-FR")}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {next_cursor ? (
        <Link href={`/sites?cursor=${encodeURIComponent(next_cursor)}`} className="underline">
          Suivant
        </Link>
      ) : null}
    </section>
  );
}
```

`web/app/(app)/sites/new/page.tsx` :

```tsx
import { requireOrg } from "@/lib/org";
import { SiteWizard } from "@/components/sites/site-wizard";

export const metadata = { title: "Nouveau site — seoinsight" };

export default async function NewSitePage() {
  await requireOrg();
  return (
    <section className="mx-auto max-w-2xl space-y-4">
      <h1 className="text-2xl font-semibold">Ajouter un site</h1>
      <SiteWizard />
    </section>
  );
}
```

`web/app/(app)/sites/[siteId]/page.tsx` :

```tsx
import { notFound } from "next/navigation";
import * as z from "zod";
import { apiFetch } from "@/lib/api";
import { ApiError } from "@/lib/api-error";
import { formatDomain } from "@/lib/format-domain";
import { formatTarget } from "@/lib/locales";
import { requireOrg } from "@/lib/org";
import { siteSchema, type Site } from "@/lib/sites";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DeleteSiteForm } from "@/components/sites/delete-site-form";
import { RenameSiteForm } from "@/components/sites/rename-site-form";
import { TargetsManager } from "@/components/sites/targets-manager";

export const metadata = { title: "Site — seoinsight" };

// États vides propres : le module Crawl remplira ces tuiles sans toucher à la structure.
const TILES = [
  { title: "Crawl", body: "Aucun run (bientôt)" },
  { title: "Search Console", body: "Non connectée" },
  { title: "Listes de mots-clés", body: "0 liste" },
];

export default async function SitePage({
  params,
}: {
  params: Promise<{ siteId: string }>;
}) {
  await requireOrg();
  const { siteId } = await params;
  if (!z.uuid().safeParse(siteId).success) notFound();
  let site: Site;
  try {
    site = siteSchema.parse(await apiFetch<unknown>(`/sites/${siteId}`));
  } catch (error) {
    if (error instanceof ApiError && error.status === 404) notFound();
    throw error;
  }
  const defaultTarget = site.targets.find((target) => target.is_default);
  return (
    <section className="space-y-8">
      <header className="space-y-1">
        <h1 className="text-2xl font-semibold">{site.name}</h1>
        <p className="text-muted-foreground">
          {formatDomain(site.domain)}
          {defaultTarget ? ` — cible par défaut : ${formatTarget(defaultTarget)}` : null}
        </p>
      </header>
      <div className="grid gap-4 sm:grid-cols-3">
        {TILES.map((tile) => (
          <Card key={tile.title}>
            <CardHeader>
              <CardTitle>{tile.title}</CardTitle>
            </CardHeader>
            <CardContent className="text-muted-foreground text-sm">{tile.body}</CardContent>
          </Card>
        ))}
      </div>
      <div className="space-y-6 border-t pt-6">
        <RenameSiteForm siteId={site.id} name={site.name} />
        <TargetsManager siteId={site.id} targets={site.targets} />
        <DeleteSiteForm
          siteId={site.id}
          domain={site.domain}
          displayDomain={formatDomain(site.domain)}
        />
      </div>
    </section>
  );
}
```

Fichiers de segment (mêmes patterns que `(app)/` — `error.tsx` en `'use client'`) :

- `sites/loading.tsx` : `export default function Loading() { return <p className="text-muted-foreground">Chargement des sites…</p>; }`
- `sites/error.tsx` : copie de `app/(app)/error.tsx` (composant renommé `SitesError`).
- `sites/not-found.tsx` : `export default function NotFound() { return <p>Page introuvable.</p>; }`
- `sites/new/loading.tsx` : `export default function Loading() { return <p className="text-muted-foreground">Chargement…</p>; }`
- `sites/new/error.tsx` : copie de `app/(app)/error.tsx` (composant `NewSiteError`).
- `sites/[siteId]/loading.tsx` : `export default function Loading() { return <p className="text-muted-foreground">Chargement du site…</p>; }`
- `sites/[siteId]/error.tsx` : copie de `app/(app)/error.tsx` (composant `SiteError`).
- `sites/[siteId]/not-found.tsx` :

```tsx
import Link from "next/link";

export default function SiteNotFound() {
  return (
    <div className="space-y-2">
      <p>Site introuvable.</p>
      <Link href="/sites" className="underline">
        Retour à la liste des sites
      </Link>
    </div>
  );
}
```

- [ ] **Step 7 : Lien « Sites » dans l'en-tête** — dans `web/components/app/header.tsx`, remplacer le premier `<Link …>seoinsight</Link>` par :

```tsx
      <div className="flex items-center gap-4">
        <Link href="/dashboard" className="font-semibold">
          seoinsight
        </Link>
        <Link href="/sites" className="text-sm underline">
          Sites
        </Link>
      </div>
```

- [ ] **Step 8 : Qualité avant PR (spec §11.3)**

```bash
cd /Users/humperdoo/Github/seoinsight-v2/web
bun run lint && bun run format:check && bun run test && bunx tsc --noEmit && bun run build
bunx knip
cd ../api && uvx ruff check . && uvx ruff format --check . && uv run pytest -q -W error
```

Expected : lint/format/tsc/build verts, vitest **15 passed**, pytest **99 passed**. `knip` : retirer tout fichier/export/dépendance mort **introduit par cette feature** ; les éventuels faux positifs préexistants sont notés, pas corrigés ici (et ne s'ajoutent pas aux devDependencies : `knip` s'exécute via `bunx`). Si `format:check` échoue : `bun run format` puis re-vérifier.

- [ ] **Step 9 : Vérification navigateur (utilisateur — exige `d3k` et l'api lancées)** — `cd api && uv run fastapi dev` (terminal 1), `cd web && d3k --no-portless` (terminal 2, port 3100 — jamais l'URL portless), puis dérouler le parcours via `d3k agent-browser`, méthode des rapports T4/T5 du plan socle :

1. Connexion `dev@seoinsight.local` / `dev-password!` → lien « Sites » dans l'en-tête → `/sites` liste le site de démo du seed (`demo.seoinsight.example`).
2. « Ajouter un site » → écran 1 : saisir un domaine réel (ex. `vercel.com`), « Vérifier » → les quatre checks s'affichent ; saisir `192.168.1.10` → erreur de champ, « Continuer » désactivé ; revenir au domaine réel.
3. Écran 2 : cible suggérée pré-remplie et marquée par défaut ; ajouter une ligne, changer la défaut (radio), retirer une ligne.
4. Écran 3 : récapitulatif, « Créer le site » → redirection `/sites/<id>`, trois tuiles (« Crawl — Aucun run (bientôt) », « Search Console — Non connectée », « Listes de mots-clés — 0 liste »).
5. Sur la page du site : renommer ; ajouter une cible, la promouvoir par défaut, supprimer l'ancienne ; tenter de supprimer la défaut → erreur 409 affichée.
6. Recréer le même domaine via le wizard → retour à l'écran 1 avec « déjà suivi dans cette organisation ».
7. Supprimer le site : bouton désactivé tant que le domaine saisi ne correspond pas, puis suppression → retour `/sites`.
8. `d3k errors` — Expected : vide.

- [ ] **Step 10 : Commit, merge dans `dev` (pas de push : l'utilisateur pousse)**

```bash
cd /Users/humperdoo/Github/seoinsight-v2 && git add -A
git commit -m "feat(web): wizard de création de site, liste et page du site, navigation Sites" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01CfD55fawp7uTDxZHj9t9g2"
git checkout dev && git merge feature/sites --ff-only && git branch -d feature/sites
```

---

## Critère de fin

- `api/` : `uv run pytest -q -W error` → **99 passed** (dont `test_migrations` : `alembic check` vert avec le registre incluant `app.sites.models`) ; `uvx ruff check .` et `uvx ruff format --check .` verts ; `alembic/versions/` contient exactement `b86e39e2c13b_init.py` + **une** révision `*_sites.py`, aucune `wip` ; `uv run python -m app.db_reset --org-id <id>` (id issu de `bun run db:seed`, jamais codé en dur) rejoue drop → migrations → seed et recrée `demo.seoinsight.example`.
- `web/` : `bun run lint`, `bun run format:check`, `bun run test` (**15 passed**), `bunx tsc --noEmit`, `bun run build` verts ; `web/drizzle/` sans aucun changement (base AUTH intouchée) ; aucune dépendance JS ajoutée.
- Parcours navigateur du Step 9 déroulé sans erreur, `d3k errors` vide.
- `dev` contient les 7 commits de `feature/sites` mergés en fast-forward ; la branche est supprimée ; rien n'a été poussé ni commité sur `main`.

## Points à confirmer par l'utilisateur

Ambiguïtés que la spec ne tranche pas — choix par défaut retenus par le plan, à valider (ou amender) avant/pendant l'exécution :

1. **Home en 4xx/5xx** : la spec (§6.2) donne le statut HTTP mais pas le mapping ; le plan retient `warning` (4xx/5xx) — jamais bloquant de toute façon. `failed` réservé aux problèmes de connexion/délai.
2. **Plusieurs directives `Sitemap:`** : la spec dit « les URLs des directives » ; le plan n'en vérifie que la **première** (travail borné, une requête), le détail du check robots affiche le compte total.
3. **Pas de champ « nom » dans le wizard** : le §7.3 n'en mentionne pas ; le nom vaut le domaine à la création, le renommage se fait sur la page du site. Ajouter un champ optionnel à l'écran 3 serait trivial si souhaité.
4. **« Renommer (dialogue) »** (§7.4) : rendu en encarts inline (formulaires sur la page du site) plutôt qu'en dialogues modaux — aucun composant shadcn `dialog`/`select` n'est installé et le plan n'ajoute pas d'ossature. À basculer en vrais dialogues (`bunx shadcn add dialog`) si l'utilisateur y tient.
5. **Dépassement du plafond de 20 cibles à l'ajout** (`POST /targets` sur un site plein) : la spec liste 404/409/422 ; le plan retient **422** (garde-fou de validation, cohérent avec le 422 du payload de création à 21 cibles).

## Couverture de la spec (auto-relecture)

| Spec §2026-08-31 | Où dans ce plan |
|---|---|
| §2 décisions (création atomique, preflight sync, UUIDv7, domaine immuable, colonnes pays/langue, non bloquant, org_id dénormalisé, www conservé) | Global Constraints + Tasks 2, 3, 4, 7 |
| §3.1–3.2 modèles + contraintes | Task 2 |
| §3.3 rattachements futurs | Aucune colonne/table créée (conforme) |
| §3.4 registre + migration unique | Task 2 (registre, wip) + Task 5 (squash, relecture SQL) |
| §4 normalisation du domaine | Task 1 |
| §5 tableau des endpoints + curseur + promotion | Task 3 (+ route preflight Task 4) |
| §6 preflight (4 checks, budget, suggestion, injection) | Task 4 |
| §7.1 frontière Zod | Task 6 |
| §7.2 routes et fichiers de segment | Task 7 Steps 2–6 |
| §7.3 wizard 3 écrans | Task 7 Step 4 |
| §7.4 liste + page du site + actions | Task 7 Steps 5–6 |
| §7.5 `format-domain`, `locales` | Task 6 |
| §8 seed | Task 5 |
| §9.1 pytest (6 fichiers + migrations) | Tasks 1, 3, 4, 5 (99 tests au total) |
| §9.2 vitest | Task 6 (15 tests au total) |
| §10 décisions tranchées | Global Constraints (valeurs exactes) |
| §11 ordre en 3 phases | Tasks 1–5 (phases 1–2 api), 6–7 (phase 3 web), une seule branche `feature/sites` |

