# Pre-prod checklist

Tout ce qui ne doit pas partir en prod en l'état. À vider avant tout merge `dev` → `main` **à partir du premier déploiement réel** (décision du 2026-08-28) : tant qu'aucun environnement de prod n'existe, `main` = intégration validée et cette liste peut rester non vide au moment du fast-forward.

| Date | Élément | Où | Action avant prod |
|---|---|---|---|
| 2026-08-26 | 2FA : afficher un QR code au lieu de l'URI brute | `web/components/auth/two-factor-settings.tsx` | Générer et afficher un QR code du `totpURI` (l'URI brute reste en repli pour la saisie manuelle) |
| 2026-08-26 | 2FA : saisie d'un code de secours sur /two-factor + codes copiables | `web/components/auth/two-factor-{form,settings}.tsx` | Exposer `authClient.twoFactor.verifyBackupCode` sur `/two-factor` et rendre les codes de secours copiables/téléchargeables |
| 2026-08-26 | JWT : `AUTH_BASE_URL` (api) doit valoir exactement `BETTER_AUTH_URL` (web) — c'est à la fois l'`iss` et l'`aud` des jetons | `api/.env`, `web/.env` | Vérifier schéma, hôte et port identiques sur chaque environnement ; un écart produit un 401 opaque sur tous les appels. La barre finale est déjà normalisée par `Settings.auth_base_url` |
| 2026-08-26 | JWT : cache JWKS de 300 s, par processus | `api/app/core/auth.py` | `PyJWKClient` refait un fetch dès qu'un `kid` est absent du cache : l'ajout d'une clé (rotation Better Auth) est donc pris en compte immédiatement, sans fenêtre de 401. Le risque réel est le **retrait** d'une clé encore utilisée par des jetons en circulation, et le cache non partagé entre workers uvicorn (un fetch JWKS par worker). Décider avant la prod : fenêtre de grâce avant suppression d'une clé, et éventuel cache partagé |
| 2026-08-26 | `apiFetch` : timeout de 10 s en dur | `web/lib/api.ts` | `AbortSignal.timeout(10_000)` s'applique à tous les appels ; à rendre paramétrable avant d'exposer des endpoints longs (exports, imports CSV, rapports) |
| 2026-08-26 | `api/` : l'image Docker ne joue pas les migrations et n'a pas de `HEALTHCHECK` | `api/Dockerfile` | Jouer `alembic upgrade head` dans un **job de déploiement séparé** (jamais au démarrage du conteneur applicatif : plusieurs répliques = migrations concurrentes) et déclarer le healthcheck sur `/health` côté compose/Dokploy plutôt que dans l'image |
| 2026-08-26 | `trustedOrigins` / `BETTER_AUTH_URL` ne sont pas déclinés par environnement | `web/lib/auth.ts`, `web/.env` | Fixer `BETTER_AUTH_URL` par environnement et ajouter les origines de preview Vercel (`https://*.vercel.app` ou le domaine de preview du projet) à `trustedOrigins`, sinon toute preview casse en CSRF |
| 2026-08-26 | Base AUTH joignable depuis Vercel : TLS et pooling | `web/lib/db.ts`, `DATABASE_URL` | `postgres.js` n'active pas SSL tout seul : imposer `?sslmode=require` (ou `ssl` explicite) dans `DATABASE_URL`, la base sortant du réseau Dedibox. Décider en parallèle de l'usage d'un **PgBouncer** (Vercel = fonctions éphémères, une connexion par invocation) |
| 2026-08-26 | Vercel Deployment Protection casserait toute l'API | Projet Vercel, `api/app/core/auth.py` | La Dedibox va chercher la JWKS sur `https://<vercel>/api/auth/jwks` : si la Deployment Protection est active, ce fetch renvoie une page d'auth et **tous** les appels API tombent en 503. Désactiver la protection, ou exclure `/api/auth/jwks` (protection bypass) |
| 2026-08-26 | `requireEmailVerification` non activé (Resend est pourtant branché) | `web/lib/auth.ts` | **Décision utilisateur : ne pas l'activer.** Ligne conservée ici pour tracer le choix ; à revoir si l'inscription devient publique |
| 2026-08-26 | Le magic link contourne la 2FA (TOTP) par design du plugin | `web/lib/auth.ts` (plugins `twoFactor` + `magicLink`) | Le plugin `twoFactor` n'intercepte que `/sign-in/email`, `/sign-in/username` et `/sign-in/phone-number` : une connexion par magic link ouvre donc une session sans second facteur. **Décision utilisateur** : accepter (le lien mail vaut facteur de possession) ou restreindre le magic link. Documenté, non modifié |
| 2026-08-26 | `fastapi run` derrière le reverse proxy : IP client et parallélisme | `api/Dockerfile`, déploiement | Fixer `FORWARDED_ALLOW_IPS` sur l'IP du reverse proxy (et non `*`) pour que `X-Forwarded-For` soit pris en compte sans être usurpable, et dimensionner `--workers` (défaut = 1) selon la machine |
| 2026-08-28 | Prod alignée sur Postgres **18** (dev = Homebrew `postgresql@18` 18.6) | Dedibox | Installer Postgres 18 (pas 17) sur la Dedibox avant le premier déploiement ; `asyncpg` et `postgres.js` sont déjà compatibles |
| 2026-08-28 | Base DATA : TLS si `api/` et Postgres ne tournent pas sur la même machine | `api/.env` (`DATABASE_URL`) | Ajouter `?ssl=require` à l'URL asyncpg dès que la base sort du réseau local de la Dedibox (l'item AUTH/Vercel du 2026-08-26 couvre l'autre base) |
| 2026-08-31 | Switcher d'organisation en lecture seule | `web/components/app/org-switcher.tsx` | La sidebar affiche l'organisation active sans permettre d'en changer. Brancher la liste des organisations et `authClient.organization.setActive` (ticket SEOINSIGHT-43) avant qu'un utilisateur appartienne à plusieurs organisations |
| 2026-08-31 | Fil d'Ariane : l'identifiant d'un site est rendu « Site » | `web/components/app/site-header.tsx` | **Atténué en T5** — un segment qui suit `sites` est libellé « Site » au lieu de « Détail », et la miette reste cliquable (`/sites/<id>` redirige vers la fiche). Le **nom réel** du site manque toujours : l'en-tête est rendu au-dessus des pages et n'a pas leurs données. Le brancher par un segment serveur ou un contexte de route |
| 2026-08-31 | Liste des sites : double pagination (curseur api + pagination client) | `web/app/(app)/sites/page.tsx`, `web/components/data-table/data-table.tsx` | La page charge 100 sites par curseur et la table pagine ce lot côté client ; au-delà de quelques centaines de sites, passer la recherche, le tri et la pagination côté api |
| 2026-08-31 | Worker jobs : aucune supervision, et la sortie sur défaut persistant est différée | `api/app/jobs/worker.py` (`run_forever`), déploiement Dedibox | `run_forever` ne meurt plus sur une erreur d'infrastructure (backoff 1 s → 30 s) : sans superviseur, un défaut **persistant** laisse donc une file gelée, process vivant. L'escalade en `CRITICAL` après 5 échecs consécutifs n'est **consommée par personne** en T0 (`basicConfig(level=INFO)` sur stdout, aucune alerte branchée). Avant prod : unité **systemd** (`Restart=always`) + collecte des logs + alerte sur `CRITICAL`, puis remplacer l'escalade par une **sortie du process** — l'emplacement est commenté dans `run_forever`. Échéance T2, premier déploiement réel du worker |
| 2026-08-31 | Worker jobs : un process occupé ne balaie pas les zombies | `api/app/jobs/worker.py` (`run_forever`) | Le balayage est séquentiel avec l'exécution : un job de 2 h retarde d'autant la requalification des zombies (spec socle §2.5, « périodiquement »). Neutralisé dès **N process worker** — le voisin balaie. Décider du nombre de process au déploiement T2, ou sortir le sweep dans sa propre tâche si un seul process est retenu |
| 2026-08-31 | Purge des mots-clés inertes : aucun mécanisme | `api/app/keywords/models.py` | Rétention org actée (décision n°4, spec Imports §3.12) : supprimer une cible garde `keywords`, `keyword_metrics` et `tags` au niveau de l'organisation. Un mot-clé qui n'est plus référencé par aucune liste ni aucun map d'aucune cible reste donc en base pour toujours, avec tout son historique de métriques (jamais écrasé, × N sources × N rafraîchissements — cas résiduel §10.6). Écrire le **job de purge différé** (ou une agrégation) avant que le réservoir d'une org n'atteigne une taille problématique ; v1 = surveillance seule |
| 2026-08-31 | Extension `vector` : provisioning superutilisateur requis avant toute migration | `scripts/db-init.sh`, Dedibox | `pg_trgm` est « trusted » (le propriétaire de la base la crée), **`vector` ne l'est pas** : `CREATE EXTENSION vector` exige un superutilisateur, alors que les migrations tournent sous le rôle applicatif. En dev, `mise run db-init` la pose dans un schéma `extensions` (hors du `public` que `app.db_reset` recrée) et met le `search_path` de la base à `public, extensions`. **Reproduire ce provisioning sur la Dedibox avant le premier `alembic upgrade head`**, sinon la migration `keywords` échoue au démarrage ; décider aussi si la prod garde le schéma `extensions` ou installe dans `public` (le type devient alors `public.halfvec`, sans effet sur les modèles qui ne qualifient jamais) |
| 2026-08-31 | Réservoir consolidé : coût linéaire en mots-clés parcourus quand le filtre porte sur une valeur **calculée** | `api/app/keywords/models.py` (fonction `keyword_metrics_latest`), futur `GET /keywords` (T3) | Benchmark T2 sur 418 714 observations réelles : une page de 50 lignes filtrée par `volume >= 1000` sur la valeur **consolidée** coûte **111 ms et 482 027 buffers** — le planificateur évalue les six latéraux de la fonction pour 65 665 mots-clés afin d'en garder 50. Ce n'est **ni la fonction ni l'index** : la fonction est correctement inlinée (`EXPLAIN` : 13 `Index Scan` sur `uq_keyword_metrics_keyword_source_fetched`, **aucun** `Function Scan` ; 31 buffers et 0,046 ms quand elle est filtrée par `keyword_id`) — c'est le **pattern de requête**. Direction validée en review, à poser **en T3** : pré-filtre « condition nécessaire » sur le brut — `EXISTS (SELECT 1 FROM keyword_metrics m WHERE m.keyword_id = k.id AND m.volume >= :v)` — puis re-filtrage sur la valeur consolidée. Sémantiquement exact (§3.3 inchangé) : la valeur consolidée est toujours celle d'une observation existante, jamais une agrégation, donc `consolidé ≥ v ⟹ ∃ brut ≥ v`. **Pas de nouvel index d'office** sur la table la plus écrite : mesurer d'abord. Si un jour insuffisant (org à plusieurs millions de mots-clés), l'étape suivante est la matérialisation de la consolidation rafraîchie en fin d'import — décision d'architecture, mesures en main, pas un réflexe. **Posé en T3** : pré-filtre EXISTS + re-filtrage exact, et la fonction est appelée en `LEFT JOIN LATERAL … LIMIT 1` — en `JOIN … ON l.keyword_id = k.id`, la corrélation reste une condition de jointure que le planificateur ne pousse pas dans le `GROUP BY keyword_id` de la fonction : il regroupait alors **toute** la table `keyword_metrics`, toutes organisations confondues, pour rendre 50 lignes (constaté sur le plan avant correction). Le `LIMIT 1` empêche aussi l'aplatissement du latéral, qui ramenait le même défaut. Rejouer le benchmark 500 k avec la requête de `app/keywords/service.py` avant la prod pour confirmer à l'échelle |
| 2026-08-31 | Re-import complet : ~120 s et ~345 Mo de WAL pour **zéro** changement | `api/app/keywords/pipeline.py` | Benchmark T2, rejeu d'un fichier de 500 000 lignes déjà importé : `rows_inserted=0`, base strictement inchangée, **zéro ligne morte** (les gardes `IS DISTINCT FROM` et `EXCLUDED.seen_at > seen_at` font leur travail) — mais le travail est fait quand même : `INSERT … ON CONFLICT` tente une **insertion spéculative par ligne** avant de constater le conflit. La stratégie reste la bonne (une pré-lecture d'existence coûterait un aller-retour par ligne, et le dedup soft sha256 du wizard court-circuite déjà le cas courant). À connaître **si le rejeu devient fréquent** — imports API récurrents de T8, notamment |
| 2026-08-31 | `errors.csv` et le rapport d'import sont plafonnés | `api/app/keywords/pipeline.py` (`ERRORS_CSV_LIMIT`, `ERROR_REPORT_LIMIT`) | Écart assumé à la spec §4.5, qui promet « la totalité des lignes en erreur » : `errors.csv` est plafonné à 50 000 lignes, la liste du rapport à 1 000. Au-delà, le rapport porte `errors_complete: false` — il **dit** la troncature au lieu de la cacher. Décider avant prod si le plafond doit monter ou si l'écran d'import doit inviter à ré-exporter (au-delà de 50 000 lignes cassées, le fichier a un problème de format, pas de données). Edge cosmétique connu : exactement 50 000 erreurs toutes collectées → `errors_complete:false` à tort |
| 2026-08-31 | `semrush_positions` n'est validé sur **aucun export réel** | `api/app/keywords/sources/semrush.py`, `api/tests/fixtures/semrush_positions_{en,fr}.csv` | Seul adaptateur v1 dont les en-têtes viennent de la documentation et des manuels EN/FR, pas d'un fichier constaté (l'export Keyword Magic, lui, est réel). Mitigé — toutes les dénominations connues acceptées (`Volume`/`Search Volume`, `Intent`/`Intents`/`Keyword Intents`, `Trend`/`Trends`…), champs exigés réduits au strict discriminant `keyword`+`url`+`position` — **pas éliminé**. Rejouer les fixtures dès qu'un export Organic Positions réel est fourni par l'utilisateur, avant d'ouvrir l'import de cette source à d'autres organisations |
| 2026-08-31 | Réservoir : le **prédicat d'appartenance** est ce qui domine le coût de `GET /keywords` | `api/app/keywords/service.py` (`_BELONGS`) | Le pattern « pré-filtre EXISTS » de la ligne ci-dessus est posé et mesuré (T3 : 2 106 buffers, 1,4 ms pour une page de 50 filtrée sur le volume consolidé, sur 3 000 mots-clés / 6 000 observations ; `EXPLAIN` sans `Function Scan` ni `Seq Scan on keyword_metrics`, gardé par `test_keywords_api.py`). **Mesuré à l'échelle 500 k (fix round 1, benchmark branché sur `service.build_query`)** : page de 50 filtrée sur le volume consolidé = **63,2 ms / 29 162 buffers** (contre 129–149 ms / 552 548 buffers pour la forme naïve), page sans filtre = 41,9 ms / 4 160 buffers, `Function Scan` et `Seq Scan on keyword_metrics` absents des deux plans. Ce qui domine ces ~60 ms n'est donc **ni la consolidation ni les métriques**, c'est l'appartenance : `membre d'une liste de la cible OU porteur d'un map sur la cible` est un `OR` de deux `EXISTS` que le planificateur matérialise en amont (Merge Semi Join sur les 417 139 items de liste de l'organisation à 500 k ; deux `SubPlan` hachés sur une petite base). Acceptable en v1 — la page reste sous les 100 ms. À re-mesurer sur une organisation à plusieurs millions d'items de liste, où le bon plan serait un parcours **piloté par la cible** (`keyword_list_items` × `keyword_page_map` → `keywords`) plutôt qu'un balayage de `keywords` filtré ensuite. Ne pas ajouter d'index par réflexe : mesurer d'abord, comme pour la ligne précédente |
| 2026-08-31 | Upload direct : plafond posé au niveau ASGI, à doubler au reverse proxy | `api/app/core/upload_guard.py`, `api/app/imports/upload.py` (`MAX_UPLOAD_BYTES`), reverse proxy Dedibox | **Corrigé en T3 (fix round 1)** : `UploadSizeGuard`, middleware ASGI sur le seul `POST /imports/files`, refuse avant routage, parsing multipart et authentification — `Content-Length` déclaré au-dessus du plafond, puis **cumul des octets réellement reçus** (chunked compris), plus un pré-filtre 401 quand l'en-tête `Authorization` est absent. Auparavant tout contrôle vivait dans le handler, donc *après* que FastAPI ait lu le corps (`await request.form()` précède la résolution des dépendances) : un `curl` sans jeton faisait ingérer 10 Go avant le 401. Reste à faire avant l'exposition publique : aligner `client_max_body_size` du reverse proxy sur `MAX_UPLOAD_BYTES` (256 Mio) — défense en profondeur, la connexion étant alors coupée avant même d'atteindre Python — et surveiller l'espace de `IMPORT_FILES_DIR` |
| 2026-08-31 | Fichiers d'import : la purge à 30 jours n'existe pas | `api/app/imports/upload.py`, `api/app/keywords/pipeline.py` (`import_file_path`) | Décision n°7 (spec §3.12) : `<org_id>/<sha256>.csv` et son `.errors.csv` sont conservés ~30 jours puis purgés, et **exclus des backups** (données des clients de l'org). T3 écrit les fichiers, rien ne les supprime. Écrire le balayage périodique du worker (et la règle d'exclusion de sauvegarde) avant le premier déploiement — sinon `IMPORT_FILES_DIR` croît sans borne. `GET /imports/{id}/errors.csv` répond déjà **410** quand le rapport annonce un fichier que le disque n'a plus (404 étant réservé au cas « aucune erreur n'a jamais été produite ») |
| 2026-08-31 | ~~`POST /imports` : la ligne `imports` est écrite **après** le commit du job~~ — **résolu en T3 (fix round 1)** | `api/app/jobs/service.py` (`enqueue_pending`), `api/app/imports/service.py` (`create_import`) | Rien à faire avant prod ; ligne conservée pour tracer la décision. Le socle a gagné `enqueue_pending`, variante d'`enqueue` qui **insère sans committer** : liste de destination, job et ligne `imports` entrent maintenant dans une seule transaction, publiée par un unique commit. La promesse « le job doit être visible d'un autre process » est intacte — c'est le commit qui rend visible, pas l'insertion. Ce que la version précédente laissait passer : un arrêt du process entre les deux commits produisait un **import fantôme** (job committé qui s'exécute, réservoir peuplé, liste remplie, et rien dans l'onglet Imports — aucun rapport, aucune trace). `enqueue_pending` ne prend **pas** de `dedup_key` : l'anti-empilement a besoin de committer pour observer le gagnant d'une course ; les appelants qui en ont besoin gardent `enqueue` et ses deux commits |
| 2026-08-31 | `imports.detected_country` : audit best-effort, muet sur la plupart des exports | `api/app/imports/service.py` (`_COUNTRY_HEADERS`) | **Alimenté depuis T3 (fix round 1)** — `POST /imports` relit les premières lignes du fichier et persiste le pays annoncé, ce qui rend vivante la colonne d'audit de §3.11. Limite assumée : la détection ne connaît qu'une poignée d'en-têtes (`Database`, `Location`, `Pays`, `Country`, `Base`) et n'accepte qu'un code à deux lettres. Les exports qui ne portent pas leur pays — Keyword Magic, Organic Positions — laissent donc `NULL`, et l'avertissement « pays du fichier ≠ cible » du wizard ne se déclenche pas pour eux. Élargir la liste au fil des fixtures réelles de T7 (SE Ranking donne `source` = pays sur 2 lettres, KWFinder donne `Location` en clair) ; la cible choisie fait de toute façon toujours autorité (§1.5), rien ne dépend de cette colonne |
| 2026-08-31 | Endpoints de la spec §6 non couverts | `api/app/imports/router.py`, `api/app/keywords/router.py` | T3 a livré l'upload, l'aperçu, la création, les listings, le réservoir et `/settings/org` ; **T5 a livré** `PUT`/`DELETE /keywords/{id}/map`, `GET /keywords/{id}/metrics`, `GET`/`POST /keyword-lists`, `POST`/`DELETE /keyword-lists/{id}/items`, `GET`/`POST /tags` et `POST`/`DELETE /keyword-tags`. Restent `POST /site-targets/{id}/reconcile` et `GET /site-targets/{id}/usage` (T6, rattachement et confirmation pédagogique de suppression). Conséquence toujours vraie : `POST /imports` **n'accepte pas** le champ `tags?` de la spec §6 — le payload contractuel du job T2 n'en porte pas ; les tags s'apposent par `POST /keyword-tags`. `extra='forbid'` en fait un 422 explicite, jamais un silence |
| 2026-08-31 | Numéro de ligne d'un import : exact, mais au prix d'un recalage borné par le plafond d'erreurs | `api/app/keywords/pipeline.py` (`_numbered`), `api/app/keywords/sources/base.py` (`to_physical_lines`) | Un adaptateur compte en **rang de données** (il ne voit jamais les lignes que le lecteur CSV a écartées), le lecteur en **numéro physique**. Le recalage les réconcilie à la fin du scan, donc `errors.csv` porte les vrais numéros du fichier de l'utilisateur — y compris quand une ligne est déchirée en milieu de fichier (le cas qui produisait une **collision**, deux erreurs sur la même ligne apparente). Limite résiduelle : si la collecte d'erreurs atteint `ERRORS_CSV_LIMIT`, la liste des lignes écartées est elle-même tronquée et le recalage des erreurs suivantes redevient approximatif. Sans effet tant que le plafond n'est pas atteint |
| 2026-08-31 | ~~Section Keywords sur `/keywords` (query `?site=&target=`)~~ — **résolu en T5 (bascule actée par l'utilisateur le 2026-09-01)** | `web/app/(app)/sites/[siteId]/keywords/`, `web/next.config.ts`, `web/proxy.ts`, `web/lib/active-site.ts` | Rien à faire avant prod ; ligne conservée pour tracer la décision. Les quatre routes vivent sous `/sites/<id>/keywords/…`, `siteIdFromPathname` reconnaît ce préfixe (le switcher marque donc le bon site), le sélecteur de site in-page a disparu, et les anciennes URL portant `?site=` sont redirigées en **308** par `next.config.ts` (sans `?site=`, elles n'identifiaient aucun site : redirection **307** vers `/settings/sites`). Trouvé et corrigé au passage : `proxy.ts` ne gardait pas `/sites/:path*`, les nouvelles routes étaient les seules pages authentifiées sans garde au bord. Reliquat : le fil d'Ariane affiche « Site » et non le nom du site (ligne dédiée ci-dessus) |
| 2026-08-31 | Wizard d'import : la liste de destination ne peut être que **nouvelle** | `web/components/keywords/import-wizard.tsx` | La spec §1.5 propose « liste nouvelle **ou existante** », et `POST /imports` accepte bien `list: {id}`. **`GET /keyword-lists` existe depuis T5** et le réservoir s'en sert déjà (filtre par liste, dialogue « ajouter à une liste ») : il ne manque plus que le sélecteur dans l'étape 3 du wizard. Un nom déjà pris remonte d'ici là en 409 lisible |
| 2026-08-31 | Onglet Imports : pas de bouton « relancer le rattachement » | `web/app/(app)/keywords/imports/page.tsx` | Flow §1.2 : chaque événement `map_reconcile` doit porter un bouton de relance. L'endpoint `POST /site-targets/{id}/reconcile` arrive en T6 avec le module Crawl ; d'ici là aucun job de ce type n'est enfilé, la colonne est donc vide et le bouton n'aurait rien à relancer. Ajouter le bouton (et le résumé réel du rapport, dont la forme sera fixée par le job) en T6 |
| 2026-08-31 | Onglet Imports : double pagination (curseur api + pagination client) | `web/app/(app)/keywords/imports/page.tsx`, `web/components/data-table/data-table.tsx` | Même dette que la liste des sites : la page charge 100 entrées par curseur et la table pagine ce lot côté client. Au-delà de quelques centaines d'imports par cible, passer la recherche, les filtres et la pagination côté api |
| 2026-08-31 | Upload direct : `NEXT_PUBLIC_API_URL` doit être joignable **depuis le navigateur** et correspondre à l'origine CORS | `web/env.ts`, `web/.env`, `api/.env` (`AUTH_BASE_URL`) | Décision n°8 : le fichier part du navigateur vers FastAPI. Deux réglages doivent s'accorder sur chaque environnement — `NEXT_PUBLIC_API_URL` (URL **publique** de l'api, pas une adresse de réseau interne : elle est inlinée dans le bundle client) et `AUTH_BASE_URL` côté api, qui est l'unique origine autorisée par `ScopedCORS`. Un écart produit une erreur réseau opaque au dépôt du fichier, jamais un message. À vérifier au premier déploiement, en même temps que le `client_max_body_size` du reverse proxy |
| 2026-08-31 | Tuile « Mots-clés » de la page d'un site : aucun décompte | `web/app/(app)/settings/sites/[siteId]/page.tsx` | La tuile ouvre la section Keywords du site mais n'affiche pas le nombre de listes (spec §7.1 : « se relie au vrai compteur »). Aucun endpoint ne le rend avant T5/T6 (`GET /keyword-lists`, `GET /site-targets/{id}/usage`). Brancher le compteur avec eux |

| 2026-09-01 | Réservoir : le filtre « intention » ne retient pas les lignes multi-intentions | `api/app/keywords/service.py` (`build_query`), `web/lib/keywords.ts` (`INTENTS`) | `to_intent` (`api/app/keywords/sources/base.py`) range plusieurs intentions dans **une** colonne texte (`"informational,commercial"`, §3.2), et `GET /keywords` compare à l'identique : filtrer sur « informational » ne retient donc pas ces lignes. Le Select de l'écran propose les quatre intentions canoniques, ce qui est juste pour le cas courant (une intention par ligne) et silencieusement partiel sinon. Trancher avant prod : soit un filtre par appartenance côté api (`intent ~ mot`, indexable via trigrammes), soit une colonne normalisée `intents text[]` — le second est une migration, à décider avec les fixtures réelles de T7 |
| 2026-09-01 | Réservoir : « Détacher » ne sait pas s'il existe une attache manuelle | `api/app/keywords/schemas.py` (`KeywordRow`), `web/components/keywords/keyword-row-actions.tsx` | `attach_status` dit `orphan`/`pending`/`attached`/`multi` **toutes sources confondues** et `GET /keywords` ne rend ni l'URL ni la source des maps d'une ligne. L'écran propose donc toujours « Détacher », et une ligne attachée par une seule observation d'import répond « Aucune attache manuelle sur cette cible » (404 relayé, lisible mais tardif). Même manque pour pré-remplir le dialogue d'attache avec l'URL déjà déclarée. À corriger en T6 avec le **picker de pages crawlées** — c'est le moment où la ligne devra de toute façon porter son URL attachée |
| 2026-09-01 | Réservoir : les onglets de source sont déduits des imports de la cible | `web/app/(app)/sites/[siteId]/keywords/page.tsx` (`loadSources`) | La spec §7.5 demande « une entrée par source **présente sur la cible** ». Aucun endpoint ne rend ces sources : l'écran les déduit des 100 derniers imports de la cible (en v1, un import est le seul chemin d'écriture du réservoir). Une source dont tous les imports sont plus anciens que ces 100 entrées perd son onglet ; la vue consolidée la montre toujours, badge de provenance compris. Ajouter `sources` à la réponse de `GET /keywords` (un `DISTINCT` sur les observations de la cible) quand une source API arrivera en T8, où la déduction par les imports CSV cessera d'être exacte |
| 2026-09-01 | `DataTable` : le mode serveur désactive tri et recherche locale, sans le dire à l'écran | `web/components/data-table/data-table.tsx` | En mode `server`, la table masque sa recherche, neutralise sa pagination et coupe le tri (`enableSorting: false`) — trier 50 lignes sur 500 000 donnerait un classement faux avec l'apparence du vrai. Conséquence assumée : le réservoir **ne se trie pas** (par volume, notamment), ce que la spec ne demande pas explicitement mais qu'un utilisateur attendra. Ajouter un tri **serveur** (`order_by` + curseur composite sur la colonne triée) quand le besoin sera confirmé : le curseur keyset actuel est sur `id DESC` seul, changer l'ordre change le curseur |
| 2026-09-01 | Réservoir : les facettes de filtre sont servies par une page bornée à 100 | `web/app/(app)/sites/[siteId]/keywords/page.tsx` (`FACET_SIZE`) | `GET /keyword-lists` et `GET /tags` sont appelés avec `limit=100` et leur `next_cursor` **n'est pas suivi** : une organisation à plus de 100 tags, ou une cible à plus de 100 listes, voit son Select incomplet. **Atténué en fix round 1** — le libellé du filtre devient « Liste (premières) » / « Tag (premiers) » dès que l'api annonce une page suivante, l'écran ne prétend donc plus être exhaustif. Reste à faire si les tags prolifèrent : un champ de recherche dans le Select, servi par un `q` côté api, plutôt qu'un chargement complet |
| 2026-09-01 | Attache en masse : le **remplacement** d'une décision existante n'est ni compté ni montré | `api/app/keywords/schemas.py` (`MapOut`), `web/app/(app)/sites/[siteId]/keywords/actions.ts` (`attachKeywordsAction`) | L'attache manuelle est unique par (cible, mot-clé) et `PUT` la **remplace** (décision n°6, spec §3.9 : « une décision se re-prend ») — c'est le bon contrat, et sur une ligne c'est exactement l'intention de l'utilisateur, qui a le mot-clé sous les yeux. En **masse**, il ne peut pas savoir lesquels des cinquante sélectionnés portaient déjà une décision vers une autre page : `MapOut` ne dit pas si la ligne a été créée ou remplacée, `GET /keywords` ne rend ni l'URL ni la source des maps (même racine que « Détacher aveugle »), et « 50 attachés » est donc indiscernable de cinquante attaches neuves. Geste destructif, irréversible, sans annulation. **Atténué en fix round 2** : le dialogue de masse avertit explicitement que toute attache manuelle existante sera remplacée. **À fermer en T6**, quand la ligne portera son URL attachée : `MapOut` rend un indicateur créé/remplacé, l'action le somme, et le décompte devient « 50 attachés, dont 10 remplacements » — puis le dialogue peut lister les URL qui vont disparaître |
| 2026-09-02 | Un import `failed` ne propose pas de **relance** | `web/components/keywords/import-report.tsx`, `web/components/keywords/import-columns.tsx` | Le silence, lui, est levé (correctif 2026-09-02) : `worker._short` écrit le motif tronqué et sans trace dans `jobs.error`, `mark_import_failed` le recopie dans `imports.error` (couvert par `test_a_failed_api_import_says_why_on_the_import_row`), l'api le rend dans `ImportOut.error` et `ImportSummary.error`, et les trois écrans l'affichent : la ligne de l'onglet Imports le porte en ton d'erreur, la page de détail et le suivi de job le rendent en `role="alert"`. Surtout, `ImportReportView` ne dit plus « Aucun rapport pour l'instant. » devant un import qui a échoué — c'est ce message qui faisait passer un échec pour une attente. Ce qui reste ouvert, et rien d'autre : **aucun bouton de relance**. Un import raté se recommence en repassant par le wizard |
| 2026-09-02 | Colonne pays d'un export : la table d'alias est courte, et la résolution est tout-ou-rien par fichier | `api/app/keywords/countries.py`, `web/lib/import-countries.ts` | `countries.py` résout un libellé par pycountry + une table d'alias (`uk`, `turkey`, `russia`) et refuse explicitement les ambiguïtés (`georgia`, `jersey`). Un export dont la colonne pays porte des **localités** (« New York », « Greater London ») ne résout rien : depuis le fix round final, le wizard **bloque** ce fichier au lieu de lancer un job qu'on sait vide, et invite à le retirer du lot — mais l'utilisateur n'a aucun moyen de mapper lui-même « New York » → `US` depuis l'écran. Décider avant prod : élargir la table d'alias au fil des fixtures réelles (T7/T8), ou offrir une correspondance manuelle libellé → pays à l'étape 3 |
| 2026-09-02 | `CREDENTIALS_KEY` **obligatoire en prod**, et aussi précieuse que la base | `api/.env`, `api/app/core/crypto.py` | Sans elle, aucune clé BYOK ne peut être enregistrée ni relue : les endpoints `/integrations` lèvent `CredentialsKeyMissing`. La poser sur chaque environnement (`python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`), la sauvegarder **séparément** des dumps Postgres (dans le même coffre, le chiffrement ne protège plus de rien) et décider de la procédure de rotation : `crypto.decrypt` rend un 409 « ressaisissez votre clé » sur un jeton illisible, ce qui est le bon message mais oblige chaque organisation à ressaisir la sienne |
| 2026-09-02 | Import API Mangools : le parse est écrit, **mais sur un seul plan et un seul jeu de captures** | `api/app/keywords/sources/mangools_api.py` | ~~Le `fetch()` ne parse rien~~ — **fait le 2026-09-02** : captures réelles prises, parse écrit dessus, `PARSER_WRITTEN` à `True`, tests sur les extraits versionnés de `api/tests/fixtures/mangools_api/`. Ce qui reste ouvert : (1) les captures viennent d'**un** compte, plan `combo` — `competitor-keywords-rows` et `reqs-per-short-period` sont lus dans `/kwfinder/limits` à chaque fetch (appel gratuit, mesuré) donc un autre plan est géré, mais aucune réponse d'un autre plan n'a été vue ; (2) `SHORT_PERIOD_SECONDS = 10` est une **estimation** : l'API ne dit pas ce qu'est une « courte période ». Surveiller les 429 au premier import client et ajuster ; (3) le plafond du corps de `POST /kwfinder/keyword-imports` n'a pas été mesuré (`KEYWORD_IMPORTS_BATCH = 100`, très en dessous des plafonds de plan visibles) — une troncature serait **loguée** en WARNING, pas masquée, mais l'import serait incomplet ; (4) **la pagination de `competitor-keywords` est le seul point déduit** : la capture n'a pris qu'une page (économie de quota), donc le *nom* du paramètre (`page`) vient du nom du champ rendu, et **la convention d'offset n'a pas été observée du tout**. Trois défenses posées faute de mieux — taille de page constante (jamais « ce qu'il me manque », qui décale l'offset et fabrique un trou), comparaison du `page` échoé, et détection de recouvrement par les `_id` déjà vus, qui arrête l'import plutôt que de payer pour relire. **À confirmer sur un vrai import de plus de 1000 lignes** : c'est le seul moyen de savoir si la page 2 tombe où l'on croit ; (5) `svn`, `svs`, `et`, `ppc`, `c` restent dans `raw` faute de sémantique confirmée — `svn` n'est pas un doublon de `sv` (mesuré) et le choix « lequel est le volume mensuel » n'a pas été deviné ; (6) `_SEARCH_TERMS` (RU, TR, CD) est **déduit, pas constaté** : ce sont les trois pays dont ni `name` ni `common_name` de pycountry n'est cherchable dans un index anglais. Aucun import réel ne les a encore traversés — élargir la table au premier échec observé, comme `countries.ALIASES` |
| 2026-09-02 | Planning d'imports : la table, l'endpoint et le sweep existent, **plus aucun écran ne les alimente** | `api/app/integrations/{models,service,router,schedule}.py`, `web/app/(app)/settings/integrations/` | **Décision produit du 2026-09-02 : pas de synchro récurrente.** L'écran Intégrations n'offre plus de cadence — un seul geste, « Importer maintenant ». Côté api, `import_schedules`, `PUT /integrations/{id}/schedules`, `cadence.advance` et `sweep_due_schedules` sont **inchangés et toujours testés** : rien n'est supprimé, ni la table ni sa migration. Conséquence à connaître avant prod : le sweep du worker tourne pour rien (aucune ligne à balayer) et l'endpoint `PUT /schedules` reste appelable **hors de l'UI** — un client d'API qui l'appellerait créerait un planning actif, qui s'exécuterait. Ce n'est pas un oubli, c'est de la machinerie dormante : soit on la rebranche (décision produit), soit on la retire avec sa migration, soit on ferme l'endpoint. À trancher, pas à laisser pourrir |
| 2026-09-02 | `Badge` n'a pas de variante de statut : le ton est posé par `className` | `web/components/ui/badge.tsx`, `web/components/app/status-badge.tsx` | Les tokens `--status-*` existent (commit `319cb5a`) et `StatusBadge` les branche par sa table `TONES`, seul endroit qui décide de la couleur d'un état. Mais il le fait en écrivant `variant="outline" className="bg-status-ok-surface text-status-ok"` : `badge.tsx` n'a **volontairement pas** été touché, pour ne pas entrer en conflit avec la tranche Réservoir qui a la même question. À réunifier après les deux merges : ajouter des variantes `ok`/`pending`/`error`/`idle` à `badgeVariants` et simplifier `TONES` à un nom de variante. Sans urgence — rien n'écrit de couleur en dur aujourd'hui |
| 2026-09-02 | Import API : pas de reprise à mi-course | `api/app/integrations/pipeline.py` | **La liste de destination, elle, est résolue** — voir la ligne « une liste Mangools = une liste chez nous » ci-dessous. Reste l'autre écart avec le CSV : le handler ne porte pas de curseur de reprise, donc un worker tué relance le fetch entier (quota consommé deux fois) ; `max_attempts=1` évite au moins la boucle. Sans effet sur le mode liste, gratuit et mesuré ; à revoir quand les volumes réels seront connus |
| 2026-09-02 | Le CPC est affiché **en dollars pour toutes les cibles** — choix assumé, pas une dette de formatage | `web/lib/keywords.ts` (`cpcFormat`) | **Décision utilisateur du 2026-09-02** : les deux `currency: "EUR"` codés en dur (réservoir et fiche d'un mot-clé) sont remplacés par un formateur unique en `USD`, `currencyDisplay: "narrowSymbol"` (« 3,05 $ » et non « 3,05 $US »). La raison : **l'api ne transporte aucune devise** — ni `keyword_metrics`, ni les adaptateurs, ni les schémas — et les valeurs viennent de Mangools, qui les rend en dollars. L'euro était donc faux sur toutes les cibles ; le dollar est exact sur celles d'aujourd'hui. Ce qui reste ouvert : le jour où une source rendra des euros (DataForSEO, un CSV Semrush européen), la devise devra **remonter de l'api** — colonne sur `keyword_metrics`, champ dans les schémas, et `cpcFormat` deviendra un formateur par devise. Rien à faire avant cette source-là, mais ne pas la brancher sans ce travail |
| 2026-09-02 | Import API : la dedup ne regarde **pas** le mode | `api/app/integrations/service.py` | La `dedup_key` est `import_api:<intégration>:<cible>:manual` avec `dedup_scope='active'` : demander un **enrichissement** pendant qu'une **découverte** tourne sur la même cible rend la découverte en cours (`created=False`), et l'écran le dit (« un import était déjà en cours »). C'est la garantie voulue — protéger le quota d'une cible — mais elle devient gênante le jour où l'on voudra enchaîner découverte puis enrichissement sans attendre. Si ce besoin apparaît : ajouter le mode à la clé, en mesurant ce qu'on perd en protection de quota |

| 2026-09-02 | Import API : **deux modes dormants** — découverte d'un domaine, enrichissement par collage | `api/app/keywords/sources/mangools_api.py` (`FetchSpec.domain` / `.keywords`, `competitor-keywords`, `keyword-imports`), `api/app/integrations/{schemas,service,pipeline}.py`, `app/(app)/sites/[siteId]/keywords/imports/new/actions.ts` (`apiImportInput`) | **Décision produit du 2026-09-02 : on n'importe que des listes déjà enregistrées dans le compte KWFinder ; le reste sera une autre feature.** Les deux modes ont quitté le wizard, et le bouton « Importer maintenant » de l'écran Réglages — qui envoyait `{site_target_id}` seul, donc une découverte que personne n'avait demandée — **a été supprimé** (correctif 2026-09-02) : la carte d'une cible renvoie vers le parcours d'import de listes. Ce qui reste : (1) l'**enrichissement** est désormais **refusé à l'enfilage** (422), parce qu'un collage de mots-clés n'a aucun nom de liste stable et écrirait donc dans le vide — l'adaptateur et le parse restent justes et testés, mais rien ne les appelle plus ; (2) la **découverte** reste enfilable hors de l'UI (`ImportTrigger.domain`, et le sweep des plannings), et elle atterrit dans une liste nommée d'après le domaine interrogé — stable d'une exécution à l'autre. À trancher, pas à laisser pourrir : soit on les rebranche (autre feature), soit on ferme les deux champs du contrat |
| 2026-09-02 | Import API : la dedup regarde la **liste**, pas le mode | `api/app/integrations/service.py` | Complète la ligne précédente sur la dedup. En mode liste, la clé devient `import_api:<intégration>:<cible>:list:<liste>:manual` — il le faut, « un import par (liste, cible) » veut dire N imports simultanés sur la même cible et la même période, et sans la liste dans la clé le deuxième retomberait sur le premier. Les deux modes dormants gardent la clé sans liste. Conséquence : un import de liste et une découverte peuvent tourner **en même temps** sur la même cible, là où deux découvertes ne le peuvent pas. Sans effet aujourd'hui (lire une liste ne consomme aucun quota, mesuré) ; à revoir si un mode dormant est rebranché |
| 2026-09-02 | Listes KWFinder : **coût mesuré sur un seul compte et un seul plan** | `api/app/keywords/sources/mangools_api.py` (`LISTS_PATH`, `LIST_FIELDS`) | Mesuré le 2026-09-02 sur le compte de l'utilisateur (plan `combo`) : `GET /kwfinder/lists` et `GET /kwfinder/lists/{id}?fields=…` ne bougent **aucun** des vingt compteurs de `resources` — trois lectures de `/kwfinder/limits` encadrant un listing et un détail (liste de 2 mots-clés) ont rendu des valeurs strictement identiques, `related-keywords` resté à 1188. Le récapitulatif annonce donc « 0 requête facturée ». Ce qui reste ouvert : (1) un seul plan observé, comme pour le reste de l'adaptateur ; (2) le coût n'a pas été mesuré sur une **grosse** liste (la plus grosse du compte fait 2106 mots-clés) — s'il existait une facturation par volume, elle ne se verrait pas sur deux mots-clés ; (3) `LIST_FIELDS` est exactement la chaîne de la mesure : la changer sans re-mesurer, c'est décrire une réponse jamais vue ; (4) le schéma publié annonce `created_at`/`updated_at` en secondes, la réponse les rend en **millisecondes** — `_moment` lit les deux, avec un seuil documenté. Vérifier ces quatre points au premier import client, et surveiller les 429 : chaque liste sélectionnée vaut une requête espacée par `_pace`, donc un recensement de 25 listes prend une minute et demie |
| 2026-09-02 | Recensement des listes KWFinder : `maxDuration = 60` posé sur le segment, **le réglage Vercel du compte reste à vérifier** | `web/app/(app)/sites/[siteId]/keywords/imports/new/page.tsx`, `web/app/(app)/sites/[siteId]/keywords/imports/new/actions.ts` | `kwfinderListsPreviewAction` ouvre une liste par requête chez le fournisseur, espacées pour respecter son débit : dix listes cochées font une quarantaine de secondes, là où tout le reste du site répond en moins d'une. Deux plafonds devaient donc bouger ensemble — le `AbortSignal.timeout(60_000)` de l'appel, **et** le `maxDuration` du segment dont les Server Actions héritent. Le second est posé, mais il n'est honoré que jusqu'au plafond du **compte** : sans Fluid compute, une fonction Vercel est tuée bien avant, la Server Action rejette, et React remplace le wizard par son `error.tsx` en emportant une sélection que rien ne reconstruit. Avant prod : vérifier que Fluid compute est actif sur le projet (ou relever le plafond du plan), et refaire un recensement de dix listes sur l'environnement déployé — c'est le seul moyen de savoir si le budget tient. Si le plan ne le permet pas, la sortie est côté api : rendre le recensement en tâche de fond plutôt que dans la requête |
| 2026-09-02 | Import API : **une liste Mangools = une liste chez nous**, retrouvée par son **nom** | `api/app/integrations/service.py` (`_destination_list`) | Réponse à la dette « `keyword_list_id` reste `NULL` », qui s'est révélée bloquante : le chemin API n'écrivait aucune appartenance, or le réservoir n'affiche un mot-clé que s'il est membre d'une liste de la cible ou porteur d'une attache d'URL (`app.keywords.service._BELONGS`). Un import de 89 mots-clés rendait `status: done`, `keywords_created: 89` — et l'écran disait « Réservoir vide ». Depuis, chaque import API a une liste de destination : le nom de la liste KWFinder en mode liste, le domaine interrogé en découverte, et un refus (422) pour l'enrichissement, qui n'a pas de nom stable. Un réimport **rafraîchit** la même liste (`UNIQUE (site_target_id, name)`), il n'en crée pas une seconde. Ce qui reste ouvert : le rattachement se fait par le **nom**, pas par l'identifiant Mangools — une liste renommée chez le fournisseur devient une seconde liste chez nous. Le corriger demanderait une colonne, donc une migration ; à faire si le cas se présente vraiment |
| 2026-09-02 | Débit Mangools : espacement **partagé par processus**, pas par instance | `api/app/keywords/sources/mangools_api.py` (`_NEXT_REQUEST_AT`) | L'espacement vivait sur l'instance du client, alors qu'on en construit une par appel : trois cibles lancées à la suite faisaient trois requêtes rapprochées sur la même clé, contre trois autorisées par courte période, et le 429 tombait dans un job à `max_attempts=1`. La mémoire est passée au niveau du module, indexée sur une empreinte SHA-256 de la clé (jamais la clé). Ce qui reste à savoir avant prod : **elle ne franchit pas la frontière du processus** — deux workers uvicorn, ou l'api et le worker, ne partagent rien et peuvent donc encore se croiser sur une même clé. Si les 429 apparaissent en vrai, la sortie est un compteur partagé (Redis, ou une table `rate_limits`), pas un intervalle plus long |
| 2026-09-02 | Recensement de listes : borné côté api, mais **`LIST_IDS_MAX` reste calibré sur une estimation** | `api/app/integrations/service.py` (`LIST_TIMEOUT_SECONDS`, `PREVIEW_BUDGET_SECONDS`, `LIST_IDS_MAX`) | Mesuré le 2026-09-02 : `GET /kwfinder/lists/{id}` a mis **17,68 s** pour la plus grosse liste du compte (2106 mots-clés, 1,9 Mo). Le plafond d'une lecture est donc passé du plafond de vérification de clé (6 s, qui coupait cette liste avant la fin — un 502 « injoignable » sur la liste principale de l'utilisateur) à 45 s, et le recensement d'une **sélection** est borné à 50 s (`PREVIEW_BUDGET_SECONDS`), sous les 60 s qu'attend la Server Action : au-delà, l'api rend un 504 qui dit de cocher moins de listes, au lieu de laisser le web abandonner sur un motif générique pendant qu'elle continue à brûler le débit du fournisseur. Ce qui reste ouvert : `LIST_IDS_MAX = 10` a été calibré sur « ~3 s par liste », or quatre grosses listes suffisent à dépasser le budget — un utilisateur qui en coche dix prendra un 504 systématique. À recalibrer sur la mesure réelle, ou à sortir de la requête (job de fond, comme le suggère déjà la ligne « maxDuration ») |

- Rate limiting sur `POST /sites/preflight` absent (fetchs sortants vers des domaines arbitraires par client authentifié) — ticket SEOINSIGHT-42 ; à trancher avant exposition publique.
