# seoinsight v2 — Module Sites : modèle, wizard, page du site

Date : 2026-08-31
Statut : proposé, en attente de relecture avant plan d'implémentation.
S'appuie sur : architecture (2026-08-26 §3, §7, §9), workflow dev & auth API (2026-08-27 §3, §4), données à grande échelle (2026-08-28 §3, §7), flow utilisateur (2026-08-28 §2, §8.1).

## 1. Objet et périmètre

Premier module métier complet (architecture §12 phase 3) : tables `sites` et `site_targets` dans DATA, CRUD FastAPI sous `CurrentOrgDep`, vérifications de preflight (DNS, `robots.txt`, sitemap), wizard 3 écrans `web/`, liste des sites, page du site avec l'état des étapes, seed réel. Les jobs (crawl, GSC, imports) sont **hors périmètre** — la page du site affiche des états vides propres ; le module Crawl branchera les vraies données au même endroit.

Aucune modification de la base AUTH : `web/drizzle/` ne bouge pas dans cette feature.

## 2. Décisions

| Sujet | Décision | Alternative écartée et pourquoi |
|---|---|---|
| Création du site | **Une seule requête atomique** `POST /sites` (domaine + cibles) à la fin du wizard ; l'état des écrans 1–2 vit côté client | Créer le site à l'écran 1 puis `PATCH` : sites orphelins à chaque wizard abandonné, états partiels à gérer partout |
| Preflight | **Endpoint synchrone** `POST /sites/preflight`, budget interne 8 s (le client `apiFetch` coupe à 10 s) | Job court : la table `jobs` n'existe pas encore (module Crawl) ; 3 requêtes bornées ne justifient ni polling ni persistance |
| Clés primaires | **UUIDv7** (`uuidv7()` natif Postgres 18, dev et prod alignés — spec 2026-08-27 §1) : ordre temporel → curseur sur une seule colonne, id opaque | `bigint identity` : expose le volume, curseur à deux colonnes ; `uuid4` : index fragmenté, aucun ordre pour le keyset |
| Domaine | **Immuable après création** ; changer de domaine = supprimer/recréer le site | Domaine éditable : invaliderait silencieusement crawls, snapshots et listes rattachés au site |
| Pays / langue | **Deux colonnes séparées**, ISO 3166-1 alpha-2 (`FR`) et ISO 639-1 (`fr`) | Locale BCP 47 combinée (`fr-FR`) : les fournisseurs (DataForSEO, flow §5) et GSC prennent pays et langue **indépendamment** ; recombiner est trivial, découper est fragile |
| Résultats du preflight | **Affichés, jamais bloquants** (seul un domaine syntaxiquement invalide arrête l'écran 1) | Bloquer sur DNS échoué : contredit « l'utilisateur n'attend jamais sur un écran bloquant » (flow §1) et empêche d'ajouter un site en préproduction ; le crawl échouera proprement de toute façon |
| `org_id` sur `site_targets` | **Dénormalisé** (en plus de `site_id`) | Le déduire par jointure : contredit « toute table métier porte `org_id` » (architecture §8) et complique le filtrage systématique par org |
| `www.` | **Conservé tel que saisi** après normalisation | Le retirer : `www.exemple.fr` et `exemple.fr` sont deux hôtes distincts pour le crawl et les propriétés GSC préfixe d'URL |

## 3. Modèle de données (DATA, SQLAlchemy — `api/app/sites/models.py`)

### 3.1 `sites`

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, `server_default=uuidv7()` |
| `org_id` | `TEXT` | NOT NULL, indexé (id Better Auth opaque, jamais de FK inter-bases) |
| `domain` | `TEXT` | NOT NULL — hôte normalisé (§4), forme ASCII/punycode, max 253 |
| `name` | `TEXT` | NOT NULL — défaut applicatif : le domaine |
| `created_at` | `TIMESTAMPTZ` | NOT NULL, `server_default=now()` |
| `updated_at` | `TIMESTAMPTZ` | NOT NULL, `server_default=now()`, `onupdate=func.now()` |

Contrainte : `UNIQUE (org_id, domain)` — un domaine n'existe qu'une fois par organisation (le multi-marché passe par les cibles, pas par des sites dupliqués ; à confirmer §10).

### 3.2 `site_targets`

| Colonne | Type | Contraintes |
|---|---|---|
| `id` | `UUID` | PK, `server_default=uuidv7()` |
| `site_id` | `UUID` | NOT NULL, FK `sites.id` `ON DELETE CASCADE`, indexé |
| `org_id` | `TEXT` | NOT NULL, indexé |
| `country` | `TEXT` | NOT NULL, `CHECK (country ~ '^[A-Z]{2}$')` |
| `language` | `TEXT` | NOT NULL, `CHECK (language ~ '^[a-z]{2}$')` |
| `is_default` | `BOOLEAN` | NOT NULL, défaut `false` |
| `created_at` | `TIMESTAMPTZ` | NOT NULL, `server_default=now()` |

Contraintes : `UNIQUE (site_id, country, language)` ; index unique partiel `UNIQUE (site_id) WHERE is_default` — **exactement une cible par défaut par site**, garanti par la base, pas seulement par l'application.

### 3.3 Rattachements futurs (préparés, non créés)

Convention pour les modules Crawl, GSC et Keywords — aucune colonne ni table maintenant :

- Toute table rattachée à un site porte `site_id UUID NOT NULL REFERENCES sites(id)` ; toute donnée par cible (crawl runs, snapshots, listes — flow §2) porte en plus `target_id UUID NOT NULL REFERENCES site_targets(id)`.
- `ON DELETE` de ces FK est décidé par la spec de chaque module ; attendu : `RESTRICT` sur `site_targets` (une cible avec historique ne se supprime pas silencieusement). Sur les tables GSC partitionnées, la spec GSC tranchera si la FK est portée ou omise (coût des FK sur partitions).
- Les UUID `site_id` / `target_id` sont stables et exposés par l'API : ce sont eux que le wizard, les jobs et les écrans référencent.

### 3.4 Migration

`api/app/db/models.py` gagne la ligne `from app.sites import models as _sites  # noqa: F401` (registre — sans elle, `alembic check` et l'autogénération ignorent les tables). Fin de feature : révisions `wip` purgées, **une** révision nommée `uv run alembic revision --autogenerate -m "sites"` au-dessus de `b86e39e2c13b` (init vide), relue en SQL : deux `CREATE TABLE`, index et contraintes ci-dessus explicites, l'index unique partiel présent (l'autogénération Alembic le détecte via `postgresql_where` ; s'il manque, il s'ajoute à la main dans la révision), aucun `DROP`. `downgrade()` supprime les deux tables.

## 4. Normalisation et validation du domaine (`api/app/sites/domain.py`)

Fonction pure `normalize_domain(raw: str) -> str`, levant `DomainError` (mappée en 422 Problem Details) :

1. Trim ; accepte une URL complète (`https://exemple.fr/page?x=1`) ou un hôte nu ; **chemin, query et fragment sont ignorés** (l'utilisateur colle souvent une URL profonde), le schéma doit être `http`/`https` s'il est présent.
2. Refus : identifiants dans l'URL, port explicite, adresse IP (v4 ou v6), hôte sans point (pas de TLD), `localhost`, point final conservé, longueur > 253.
3. Minuscules, puis encodage IDNA (punycode) via la lib `idna` — qui valide au passage les labels (longueur, caractères, tirets). La forme stockée est l'ASCII (`xn--…`) ; l'affichage Unicode est l'affaire de `web/` (§7.5).
4. `www.` et tout autre sous-domaine sont conservés : le site est l'hôte saisi.

## 5. API (`api/app/sites/` : `models.py`, `schemas.py`, `service.py`, `router.py`, `domain.py`, `preflight.py`)

Tous les endpoints prennent `CurrentOrgDep` (`app.core.auth`) : sans org active → 404 Problem Details, déjà en place. Toute requête filtre `org_id == user.org_id` ; un site d'une autre org → 404 (jamais 403, architecture §9). Aucun `SELECT *` : les requêtes énumèrent les colonnes des schémas de réponse ; les cibles d'un site sont chargées par `selectinload` (volume borné, §10 plafond).

| Méthode & chemin | Corps / paramètres | Réponse | Erreurs |
|---|---|---|---|
| `POST /sites/preflight` | `{domain}` | rapport §6 | 422 domaine invalide |
| `POST /sites` | `{domain, name?, targets: [{country, language, is_default}]}` — 1..n cibles, **exactement une** `is_default: true` | 201, site + cibles | 422 validation ; 409 domaine déjà présent dans l'org |
| `GET /sites` | `?cursor=&limit=` (défaut 25, max 100) | `{items: [site + cibles], next_cursor}` | 422 curseur illisible |
| `GET /sites/{site_id}` | — | site + cibles | 404 |
| `PATCH /sites/{site_id}` | `{name}` (le domaine est immuable) | site + cibles | 404, 422 |
| `DELETE /sites/{site_id}` | — | 204 | 404 |
| `POST /sites/{site_id}/targets` | `{country, language}` | 201, cible | 404, 409 couple déjà présent, 422 |
| `PATCH /sites/{site_id}/targets/{target_id}` | `{is_default: true}` | cible | 404, 422 (`false` refusé : on promeut une autre cible, on ne « dé-défaute » pas) |
| `DELETE /sites/{site_id}/targets/{target_id}` | — | 204 | 404 ; 409 si cible par défaut (en promouvoir une autre d'abord) — la dernière cible est toujours la défaut, donc jamais supprimable |

- **Création** : `normalize_domain`, pré-vérification du doublon pour un message propre, insertion site + cibles dans une transaction ; la contrainte `UNIQUE (org_id, domain)` reste le filet anti-course (`IntegrityError` → 409). `country`/`language` validés par pattern Pydantic (`^[A-Z]{2}$` / `^[a-z]{2}$`) avant les CHECK.
- **Pagination par curseur** (règle projet) : `ORDER BY id DESC` (UUIDv7 = ordre de création), `WHERE id < :cursor` ; curseur = l'UUID du dernier item en base64url, opaque pour le client ; `next_cursor: null` en fin de liste. Illisible/malformé → 422.
- **Promotion de défaut** : dans une transaction, `UPDATE … SET is_default = false WHERE site_id = :sid AND is_default` puis `SET is_default = true` sur la cible visée — l'index unique partiel garantit l'invariant même en cas de course.
- **`updated_at`** : géré par l'ORM (`onupdate`) ; suffisant tant qu'aucun bulk update ne contourne l'ORM (aucun ici).

## 6. Preflight (`api/app/sites/preflight.py`)

`POST /sites/preflight` — synchrone, sans persistance, lecture seule. Quatre vérifications, statuts `ok | warning | failed | skipped` :

1. **DNS** : résolution A/AAAA de l'hôte (`loop.getaddrinfo`, timeout 2 s). Échec → les trois autres passent `skipped` (inutile de fetcher un hôte qui ne résout pas). Si **toutes** les adresses résolues sont privées/réservées (RFC 1918, loopback, link-local, via `ipaddress`) → `failed` « domaine non public » et fetchs `skipped` : garde-fou SSRF — pas une défense anti-rebinding complète, acceptable pour un endpoint authentifié qui ne renvoie que des statuts.
2. **Page d'accueil** : `GET https://{host}/` (repli `http` si la connexion TLS échoue), redirections suivies (max 5), timeout 5 s, lecture plafonnée à 64 Ko. Fournit : statut HTTP, hôte final après redirections (affiché si différent de l'hôte saisi — l'utilisateur voit que `exemple.fr` redirige vers `www.exemple.fr` et peut corriger avant de créer), attribut `lang` de `<html>` (extraction tolérante par expression régulière sur les octets lus — pas de dépendance de parsing pour un seul attribut).
3. **`robots.txt`** : `GET https://{host}/robots.txt`, timeout 5 s, plafond 512 Ko ; trouvé/absent + directives `Sitemap:` extraites (parsing ligne à ligne, pas de lib).
4. **Sitemap** : les URLs des directives `Sitemap:` si présentes, sinon `GET https://{host}/sitemap.xml` (statut + type de contenu, corps non parsé). Absent → `warning`, jamais `failed`.

2–4 tournent en parallèle (`asyncio.gather`) sous un budget global de 8 s ; un dépassement individuel rend `failed` avec détail « délai dépassé », **jamais** un 5xx de l'API — le site cible lent n'est pas une erreur serveur.

Réponse (`PreflightReport`) :

```json
{
  "domain": "exemple.fr",
  "checks": {
    "dns": {"status": "ok", "detail": null},
    "home": {"status": "ok", "detail": "200 — https://www.exemple.fr/"},
    "robots": {"status": "ok", "detail": "trouvé (2 directives Sitemap)"},
    "sitemap": {"status": "ok", "detail": "https://exemple.fr/sitemap.xml"}
  },
  "final_host": "www.exemple.fr",
  "suggested_target": {"country": "FR", "language": "fr"}
}
```

**Suggestion de cible** (flow §2, écran 2 pré-rempli) : `language` = sous-étiquette primaire de l'attribut `lang` si présent, sinon la langue associée au ccTLD, sinon `en` ; `country` = ccTLD s'il figure dans la table statique (~20 entrées : `fr→FR`, `de→DE`, `es→ES`, `it→IT`, `uk→GB`, `be→BE`, `ch→CH`, `nl→NL`, `pt→PT`, `pl→PL`, …), sinon `US`. La table vit dans `preflight.py` ; c'est une **suggestion** éditable, pas une décision.

Testabilité : le client `httpx.AsyncClient` et la fonction de résolution DNS sont injectables (paramètres avec défauts au niveau du module, remplacés en test — même approche que `set_jwks_client`).

## 7. `web/`

### 7.1 Frontière typée

`web/lib/sites.ts` : schémas Zod (`siteSchema`, `siteListSchema`, `preflightReportSchema`, `targetSchema`) — toute réponse d'`apiFetch` les traverse (`schema.parse`), conformément à la règle « toute donnée traversant une frontière passe par Zod ». Les types TS en sont inférés (`z.infer`).

### 7.2 Routes et fichiers

```
web/app/(app)/sites/
├── page.tsx               # liste des sites
├── loading.tsx  error.tsx  not-found.tsx
├── new/
│   ├── page.tsx           # coquille serveur, rend <SiteWizard/>
│   ├── actions.ts         # 'use server' : preflightAction, createSiteAction
│   └── loading.tsx  error.tsx
└── [siteId]/
    ├── page.tsx           # page du site
    ├── actions.ts         # 'use server' : renameSiteAction, deleteSiteAction, cibles
    ├── loading.tsx  error.tsx  not-found.tsx
```

Pages = Server Components (`const result = await apiFetch(...)` puis parse Zod) ; `'use client'` uniquement aux feuilles interactives. Lien « Sites » ajouté dans `components/app/header.tsx`. Pas d'appel `api/` depuis le navigateur : tout passe par les Server Actions et les Server Components (architecture §5).

### 7.3 Wizard `/sites/new` — 3 écrans, un composant

`components/sites/site-wizard.tsx` (`'use client'`) : les trois écrans sont des étapes d'un même composant, l'état (domaine, rapport de preflight, cibles) vit en React state — rien n'est persisté avant l'écran 3 (décision §2).

1. **Domaine** : champ unique ; « Vérifier » appelle `preflightAction(domain)` (validation Zod côté serveur, `apiFetch POST /sites/preflight`) ; les quatre checks s'affichent (ok/warning/failed) ; si `final_host` diffère, proposition de basculer sur l'hôte final. Rien ne bloque « Continuer » sauf un domaine invalide (422 relayé comme erreur de champ).
2. **Cibles** : tableau éditable pré-rempli avec `suggested_target` marquée par défaut ; ajout/suppression de lignes (pays et langue choisis dans des `<select>` alimentés par `web/lib/locales.ts`, §7.5) ; exactement une ligne « par défaut » (radio).
3. **Lancement** : récapitulatif ; « Créer le site » appelle `createSiteAction` (Zod → `apiFetch POST /sites` → `revalidatePath('/sites')` → `redirect('/sites/{id}')`). L'encart « le crawl démarrera ici automatiquement » est un texte statique : le déclenchement réel arrive avec le module Crawl. Un 409 (domaine déjà suivi) revient comme erreur affichée à l'écran 1.

Les actions retournent `{ error: string } | never` (redirect) ; `ApiError` est attrapée et son `detail` relayé — jamais d'erreur brute du serveur au navigateur (architecture §9).

### 7.4 Liste et page du site

- **`/sites`** : tableau (domaine affiché en Unicode, nom, cibles en badges `FR · fr`, date de création) ; état vide avec CTA « Ajouter un site » ; pagination par lien « Suivant » (`?cursor=` → `searchParams`, repassé à `GET /sites`).
- **`/sites/[siteId]`** : en-tête (nom, domaine, cibles avec la défaut marquée) + **état des étapes** en trois tuiles, données réelles quand elles existeront, états vides propres aujourd'hui : « Crawl — aucun run (bientôt) », « Search Console — non connectée », « Listes de mots-clés — 0 liste ». Le module Crawl remplacera le contenu des tuiles sans toucher à la structure de la page. Actions : renommer (dialogue), gérer les cibles (ajouter, promouvoir par défaut, supprimer), supprimer le site (confirmation explicite citant le domaine). `ApiError` 404 → `notFound()` dans la page, rendu par `not-found.tsx`.

### 7.5 Logique pure (testée vitest)

- `web/lib/format-domain.ts` : `formatDomain(ascii: string): string` — punycode → Unicode via `domainToUnicode` (`node:url`), repli sur l'entrée si la conversion échoue. Server-only de fait (appelée dans des Server Components).
- `web/lib/locales.ts` : listes `{code, label}` des pays (ISO 3166-1 alpha-2) et langues (ISO 639-1) proposés dans les `<select>`, libellés français en dur (sous-ensemble d'~30 pays / ~20 langues couvrant les marchés de l'agence, extensible) ; `formatTarget({country, language})` → `"France · français"`.

## 8. Seed (`api/app/seed.py`)

`run(org_id)` cesse d'être un no-op : si aucun site `demo.seoinsight.example` n'existe pour l'org (`SELECT` préalable — idempotent, relançable), insertion directe SQLAlchemy d'un site « Site de démonstration » avec une cible `FR`/`fr` par défaut, puis impression d'un récapitulatif (`SITE_ID=…`). 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` existant. `app.db_reset` enchaîne déjà drop → migrate → seed sans changement.

## 9. Tests

### 9.1 pytest (`api/tests/`, base `data_test`, `-W error`)

Le `conftest.py` existant (migrations en fixture de session, `_FakeJwksClient`, client ASGI) est réutilisé ; s'y ajoute une fixture `auth_headers(org_id)` qui signe un JWT EdDSA avec `test_private_key`.

- `test_sites_domain.py` — `normalize_domain` pur : URL complète, hôte nu, IDN → punycode, casse, refus (IP, port, `localhost`, sans TLD, identifiants, > 253).
- `test_sites_crud.py` — création avec cibles (201, défaut posée), doublon → 409, deux `is_default` ou zéro → 422, `GET`/`PATCH`/`DELETE`, domaine immuable (absent du schéma PATCH), cibles : ajout, doublon couple → 409, promotion par défaut (l'ancienne est démise), suppression de la défaut → 409, `DELETE` site → cibles disparues (cascade).
- `test_sites_org.py` — filtrage : le site de l'org A est invisible pour l'org B (404 sur `GET`/`PATCH`/`DELETE`, absent de la liste) ; sans org active → 404 (déjà couvert par `test_org.py` pour la dépendance, revérifié sur une route réelle).
- `test_sites_cursor.py` — N sites, parcours complet par pages : ordre stable (plus récent d'abord), aucune répétition ni trou, `next_cursor` nul en fin, curseur corrompu → 422, `limit` hors bornes → 422.
- `test_sites_preflight.py` — résolveur DNS et transport httpx **mockés** (`httpx.MockTransport` + monkeypatch du résolveur) : tout ok ; DNS échoué → autres `skipped` ; IP privée → `failed` ; robots absent → `warning` ; sitemap via directive `Sitemap:` ; `lang="de-CH"` → suggestion `de` ; timeout simulé → `failed` sans 5xx. Aucun accès réseau réel.
- `test_seed.py` — étendu : `seed.run` crée le site de démo, second appel = no-op (idempotence).
- `tests/test_migrations.py` (`alembic check`) couvre mécaniquement le nouveau registre : rien à écrire, il échouerait si modèle et migration divergeaient.

### 9.2 vitest (`web/lib/`)

`format-domain.test.ts` (punycode → Unicode, repli), `locales.test.ts` (`formatTarget`, codes inconnus), et le parsing Zod de `sites.ts` sur des fixtures de réponses API (dont une réponse malformée → throw).

## 10. Décisions (tranchées par Bastien le 2026-08-31)

1. **Unicité `(org_id, domain)`** : un hôte = un site par organisation ; le multi-marché vit dans `site_targets`. Une agence peut avoir des dizaines/centaines de sites *différents* (liste paginée par curseur, index `org_id`) — la contrainte n'empêche que le doublon du même domaine.
2. **Suppression : `ON DELETE CASCADE`** + confirmation UI forte (saisie du domaine). Les fichiers de crawl sur disque seront traités dans la spec Crawl.
3. **Formats : ISO 3166-1 alpha-2 (pays) + ISO 639-1 (langue)**, mapping vers les référentiels fournisseurs porté par chaque adaptateur. **Plafond de 20 cibles par site** (constante applicative, garde-fou anti-abus).
4. **`www.` conservé tel que saisi** ; quand le preflight détecte une redirection vers un autre hôte, l'écran 1 pré-coche la bascule vers `final_host`.

## 11. Ordre de mise en œuvre

1. **`api/` socle du module** : `domain.py` + tests purs ; modèles + registre + révision `sites` ; CRUD + curseur + tests (CRUD, org, curseur) ; seed réel + test d'idempotence.
2. **`api/` preflight** : `preflight.py` (DNS, home, robots, sitemap, suggestion), injection des mocks, tests.
3. **`web/`** : `sites.ts` (Zod) + `format-domain`/`locales` + vitest ; wizard + actions ; liste + page du site + fichiers de segment ; lien de navigation ; passage `knip`/oxlint avant la PR vers `dev`.

Chaque phase reste sur la même branche `feature/sites` (une feature = au plus une migration par base) ; le squash Alembic et `bun run db:reset` / `uv run pytest -q -W error` clôturent la feature avant le merge.
