# seoinsight v2 — Architecture et structure du repo

Date : 2026-08-26
Statut : validé en brainstorming, en attente de relecture finale avant plan d'implémentation.

## 1. Contexte et objectifs

Refonte complète de l'app **seoinsight** (suite SEO d'agence, multi-clients). La v1 (`~/Github/seo-insight`, 1327 commits) a accumulé trop d'erreurs ; elle sert uniquement à identifier le périmètre fonctionnel. **Aucun code, schéma ou structure de la v1 n'est repris**, à l'exception probable de modules Rust (voir §11).

Objectifs non négociables :

- **Très simple.** Pas d'abstraction « au cas où », pas d'outillage qui n'a rien à orchestrer.
- **Next.js le plus léger possible.** Aucune logique métier dans le front.
- **Aucune dépendance à un cloud payant** pour les données : Postgres self-hébergé.
- **Prêt pour du gros volume** dès le jour 1 (schéma, index, partitionnement), sans optimisation prématurée.
- **CLIs officiels uniquement** pour toute ossature (`bun create next-app`, `bunx --bun shadcn@latest init`, `uv init`, etc.). Jamais de squelette écrit à la main.
- **Aucune décision structurante sans accord explicite de l'utilisateur.**

## 2. Périmètre v2

Quatre blocs, tous repensés (flux utilisateur différent de la v1) :

1. **Sites / projets + auth multi-organisations** (socle).
2. **GSC** — connexion Google Search Console, snapshots quotidiens requêtes/pages, visualisation.
3. **Pages / crawl on-page** — crawl d'un site, issues, score par page.
4. **Keywords** — réservoir de mots-clés, import CSV, recherche, planning.

### Non-objectifs (hors v2)

Site vitrine, dashboard d'acquisition, i18n, Stripe/billing, support/ticketing, superadmin, backlinks, contenu, flight-checker, login social. Ils pourront revenir plus tard, chacun avec sa propre spec.

## 3. Architecture

```
┌──────────────────────────┐   HTTPS + JWT    ┌──────────────────────────────────────┐
│  web/  (Next.js 16)      │ ───────────────▶ │  api/  (FastAPI)                     │
│  Vercel                  │                  │  Dedibox                             │
│                          │                  │                                      │
│  UI (shadcn)             │                  │  API métier + API publique (MCP/CLI) │
│  Better Auth             │                  │  Jobs longs (crawl, imports)         │
│   ├ organization         │                  │  Crons (snapshots GSC)               │
│   ├ magicLink, twoFactor │                  │  OAuth Google Search Console         │
│   └ jwt (JWKS)           │                  │  SQLAlchemy 2 + Alembic              │
│  Drizzle (pg-core)       │                  │                                      │
└───────────┬──────────────┘                  └───────────────────┬──────────────────┘
            │                                                     │
            ▼                                                     ▼
   ┌─────────────────┐                                  ┌─────────────────┐
   │ Postgres : AUTH │                                  │ Postgres : DATA │
   └─────────────────┘                                  └─────────────────┘
            └──────────── même instance Postgres (Dedibox) ────────────┘
```

### Règles de propriété

| Brique | Propriétaire | Outil | Personne d'autre ne touche |
|---|---|---|---|
| Base **AUTH** (user, session, account, organization, member, invitation, two_factor, jwks…) | `web/` | Drizzle + `drizzle-kit` + Better Auth CLI | `api/` ne s'y connecte jamais |
| Base **DATA** (sites, keywords, pages, GSC, jobs, intégrations) | `api/` | SQLAlchemy 2 + Alembic | `web/` ne s'y connecte jamais |

Le seul lien entre les deux est le **JWT** émis par Better Auth et vérifié par FastAPI. Les identifiants `user_id` et `org_id` sont stockés dans DATA comme chaînes opaques ; aucune jointure inter-bases.

### Pourquoi ces choix (résumé des alternatives écartées)

- **Turso / libSQL** (choix initial) écarté : clients Python expérimentaux ou peu maintenus (`libsql` sept. 2025, `sqlalchemy-libsql` « not production grade »), et refus de dépendre d'un cloud payant.
- **Une seule base partagée** écartée : deux outils de migration sur les mêmes tables et une validation de session à réimplémenter côté Python.
- **FastAPI = simple moteur de jobs, Next propriétaire des données** écarté : contredit « Next le plus léger possible » et « API exposée à d'autres clients ».
- **Moteur différent en dev et prod** (SQLite/Turso en dev, Postgres en prod) écarté : Drizzle et SQLAlchemy ont des schémas spécifiques au dialecte ; on aurait deux schémas et des bugs visibles uniquement en prod.
- **DuckDB comme base de recherche** écarté : moteur in-process mono-écrivain, inadapté aux requêtes concurrentes de l'app. Réservé au chemin d'import CSV (§8).

## 4. Structure du repo

Un seul repo, deux applications indépendantes, **sans workspaces ni Turborepo** (elles ne partagent aucun code : TS d'un côté, Python de l'autre).

```
seoinsight-v2/
├── web/                        # Next.js 16 → Vercel (Root Directory = web)
├── api/                        # FastAPI → Dedibox
├── crates/                     # modules Rust, vide au départ (§11)
├── scripts/db-init.sh          # dev : rôle et bases sur le Postgres Homebrew (seul script du repo, `mise run db-init`)
├── docs/superpowers/specs/     # specs de design
├── CLAUDE.md                   # règles projet (+ @import nextjs-16.x)
├── .gitignore
└── README.md
```

Dev local : `brew services start postgresql@18` puis `mise run db-init` (une fois), ensuite `d3k` dans `web/` et `uv run fastapi dev` dans `api/` (amendé par la spec 2026-08-27 §2 : Postgres Homebrew, plus de Docker).

Git : `main` = prod (jamais de commit direct, fast-forward depuis `dev` sur demande explicite), `dev` = intégration, `feature/*` / `fix/*` éphémères. Conventional Commits. Remote GitHub privé `bastienallain/seoinsight-v2` (le compte gh actif n'a pas les droits sur l'org elevaseo ; transférable plus tard).

## 5. `web/` — Next.js

- **Création** : `bunx --bun shadcn@latest init -t next -n web` (crée le projet Next 16 App Router + Tailwind + shadcn en une commande, demande explicite de l'utilisateur).
- **Runtime / PM** : bun. Installs de nouvelles dépendances via `sfw bun add`.
- **Auth** : Better Auth avec plugins `organization`, `magicLink` (Resend), `twoFactor` (TOTP), `jwt`. Email + mot de passe activé. Pas de provider social.
- **DB** : Drizzle `pg-core` + driver `postgres`, base AUTH uniquement. Schéma auth généré par la CLI Better Auth (`bunx @better-auth/cli generate`), migrations `drizzle-kit`.
- **Env** : validation stricte au build avec T3 Env (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `RESEND_API_KEY`, `API_URL`).
- **Lint / format** : oxlint + oxfmt. `knip` avant toute PR sensible.
- **Accès à l'API** : **côté serveur uniquement** au départ (Server Components, Server Actions, route handlers). Le navigateur n'appelle jamais `api/` directement — pas de CORS à gérer, le JWT ne quitte pas le serveur Next. Si un besoin temps réel/client apparaît, on ouvrira CORS pour l'origine Vercel à ce moment-là.

## 6. `api/` — FastAPI

- **Création** : `uv init`, Python 3.13+, `fastapi[standard]`, `sqlalchemy[asyncio]`, `asyncpg`, `alembic`, `pydantic-settings`, `pyjwt[crypto]` (JWT + cache JWKS via `PyJWKClient`), `httpx`.
- **Lint / format** : ruff. Tests : pytest.
- **Layout proposé** (à ajuster au scaffolding, pas d'architecture hexagonale) :

```
api/
├── app/
│   ├── main.py             # création de l'app, routers
│   ├── core/               # settings (pydantic-settings), auth (JWT), erreurs
│   ├── db/                 # engine, session, base Alembic
│   ├── sites/              # router.py, models.py, schemas.py, service.py
│   ├── gsc/                # idem + oauth.py + snapshots.py
│   ├── pages/              # idem + crawl.py
│   ├── keywords/           # idem + import_csv.py (DuckDB)
│   ├── jobs/               # table jobs, worker
│   └── cli.py              # commandes (crons, maintenance)
├── alembic/
├── tests/
├── Dockerfile
└── pyproject.toml
```

Un module = un dossier plat (router, modèles, schémas, service). Pas de couche `repository`, pas d'interfaces abstraites.

- **Auth des requêtes** : dépendance FastAPI qui vérifie le JWT Better Auth (signature via la JWKS de `web/`, mise en cache, rafraîchie sur `kid` inconnu), extrait `user_id` et `org_id`, et les injecte dans les handlers. **Toute requête DATA est filtrée par `org_id`.** Clés API (pour MCP/CLI/tiers) : à spécifier plus tard, dans la même dépendance.

## 7. Flux de données (proposé — à valider)

**Connexion** : l'utilisateur se connecte sur `web/` (Better Auth). Le plugin `jwt` expose `GET /api/auth/token` et `GET /api/auth/jwks`. Le payload du JWT est défini via `definePayload` : `sub` (user_id), `org_id` (organisation active), `email`. Durée de vie courte (15 min) ; `web/` en redemande un à Better Auth quand il expire.

**Appel API** : un Server Component / Server Action de `web/` obtient le JWT courant et appelle `api/` avec `Authorization: Bearer`. Un seul petit client `fetch` typé côté `web/` (`lib/api.ts`), pas de SDK généré au départ.

**Jobs longs (crawl, import CSV)** :

1. `web/` appelle `POST /sites/{id}/crawls` → `api/` insère une ligne `jobs` (status `queued`) dans DATA et répond `202` avec l'id.
2. Un **process worker** (`uv run python -m app.jobs.worker`) sur la Dedibox dépile la table `jobs` avec `SELECT … FOR UPDATE SKIP LOCKED`, exécute, écrit les résultats dans DATA, passe le job à `done` ou `failed` (avec message d'erreur).
3. `web/` affiche l'état par polling de `GET /jobs/{id}`.

Pas de Redis, pas de Celery : une table et un process. Prouvé sur la v1 dashboard.

**Crons (snapshots GSC quotidiens)** : commandes `app/cli.py` (ex. `uv run python -m app.cli gsc-sync`) planifiées par `cron` / `systemd timer` sur la Dedibox. Aucun scheduler dans le process API.

**GSC** : `api/` fait son propre flux OAuth Google (`GET /integrations/google/connect` → consentement → callback). Le refresh token est chiffré (Fernet, clé en env) et stocké dans DATA, rattaché à l'org et au site. La synchro quotidienne l'utilise pour appeler `searchAnalytics/query`. `web/` n'a qu'un bouton « Connecter Search Console » qui redirige vers `api/`.

## 8. Données (DATA) — principes, pas le schéma détaillé

Le schéma de chaque module sera conçu dans sa propre spec de feature. Principes fixés maintenant :

- Toute table métier porte `org_id` (indexé), les tables rattachées à un site portent `site_id`.
- **Snapshots GSC partitionnés par mois** (`gsc_query_snapshots`, `gsc_page_snapshots`) dès la création, ordre de grandeur cible 10–100 M lignes/an. Tables d'agrégats précalculés seulement si une mesure le justifie.
- Recherche texte sur les keywords : `pg_trgm` + index GIN.
- Pas de `SELECT *` ; pagination systématique par curseur sur les grosses tables.
- **DuckDB** uniquement embarqué dans `keywords/import_csv.py` (lecture/normalisation de gros CSV Semrush/Ahrefs/Mangools, puis `COPY` vers Postgres). Ce n'est pas une base, c'est une librairie d'import.
- Secrets d'intégration (refresh tokens) chiffrés au repos.

## 9. Gestion des erreurs (proposé — à valider)

- `api/` renvoie des erreurs JSON au format **Problem Details (RFC 9457)** : `type`, `title`, `status`, `detail`. Un seul handler global ; pas d'exceptions métier par cas avant d'en avoir besoin.
- JWT invalide/expiré → `401` ; `org_id` sans accès → `404` (pas de fuite d'existence).
- Jobs : `status` + `error` texte + `attempts` ; 3 tentatives max, ensuite `failed`. Pas de dead-letter queue.
- `web/` : le client `lib/api.ts` transforme les Problem Details en erreur typée ; affichage via toast shadcn. Erreurs serveur loguées, jamais renvoyées brutes au navigateur.
- Env manquante → échec au démarrage (T3 Env côté web, `pydantic-settings` côté api). Pas de valeur par défaut silencieuse en prod.

## 10. Tests (proposé — à valider)

- `api/` : pytest contre une base `data_test` du même Postgres Homebrew (migrations Alembic appliquées au setup). Tests d'intégration des routers via `httpx.AsyncClient`, JWT signé par une clé de test. C'est là que vit l'essentiel de la logique, donc l'essentiel des tests.
- `web/` : très peu de logique → vitest sur `lib/` uniquement. Pas de Playwright au départ.
- CI (GitHub Actions) : lint + tests des deux apps, ajoutée au moment du premier déploiement.

## 11. Points ouverts (décisions reportées, explicitement)

| Sujet | Décision reportée à |
|---|---|
| Déploiement de `api/` sur la Dedibox (Dokploy vs compose manuel) | premier déploiement ; le `Dockerfile` est écrit dans tous les cas |
| PgBouncer devant AUTH (Vercel serverless) | premier déploiement |
| Modules Rust conservés (`import-core`, `semantic-core`, `gsc-worker` ?) et mode d'intégration (PyO3 / sous-process / WASM) | la spec de la première feature qui en a besoin |
| Clés API pour MCP / CLI / tiers | spec dédiée, après le socle |
| Schémas détaillés de chaque module | une spec par module (sites, GSC, pages, keywords) |

## 12. Ordre de mise en œuvre

Chaque phase = sa branche `feature/*`, ses tests, son merge dans `dev`.

0. **Squelette du repo** : `git init`, remote, `scripts/db-init.sh` + `mise.toml`, `CLAUDE.md`, `README.md`, `.gitignore`.
1. **`web/` socle** : `bun create next-app`, shadcn, T3 Env, Drizzle → AUTH, Better Auth (email/mdp, magic link, 2FA, organization, jwt), écrans login/inscription/org.
2. **`api/` socle** : `uv init`, FastAPI, settings, SQLAlchemy + Alembic → DATA, vérification JWT, `GET /me` de bout en bout depuis `web/`.
3. **Sites** : CRUD sites par org (premier module complet, modèle pour les suivants).
4. **GSC** : OAuth Google côté api, snapshots partitionnés, cron, écrans.
5. **Pages / crawl** : table jobs + worker, crawl, issues, score.
6. **Keywords** : réservoir, import CSV (DuckDB), recherche `pg_trgm`.

Les phases 0–2 forment le premier plan d'implémentation. Les phases 3–6 auront chacune leur spec.
