# Worker Rust — snapshots GSC datés

**Date:** 2026-06-10 · **Linear:** [ELEVA-40](https://linear.app/elevaseo/issue/ELEVA-40)
**But:** relevés GSC **datés** (par jour) persistés → débloque les pages **Stats** (contenu, F2/F3 : perf par page + tendances) et les **tendances temporelles keywords** (Trending now). Inaugure la stack **workers Rust** du cahier.

**Décisions (brainstorm):** binaire **Rust standalone** (`crates/gsc-worker`) ; **token broker** côté app (Better Auth chiffre les tokens — `encryptOAuthTokens: true`, auth.ts:42 — on ne réimplémente PAS son crypto en Rust) ; fenêtre glissante **J-5 → J-1** ; deux dimensions (`date×query`, `date×page`) ; cron externe, pas de queue (YAGNI).

---

## A. Architecture

```
cron (Dokploy / manuel dev)
  └─ crates/gsc-worker (binaire Rust : tokio + reqwest + sqlx + tracing)
       ├─ SELECT sites WHERE gsc_property IS NOT NULL   (Postgres direct, sqlx)
       ├─ POST {APP_URL}/api/worker/gsc-token {siteId}  (Bearer WORKER_API_KEY)
       │     └─ l'app : site→org→owner→compte google→auth.api.getAccessToken
       │        (refresh + déchiffrement gérés par Better Auth)
       ├─ POST GSC searchAnalytics  dimensions [date,query] puis [date,page]
       └─ UPSERT gsc_query_snapshots / gsc_page_snapshots + 1 ligne gsc_snapshot_runs
```

Pas de scheduler interne ni de file d'attente : 1 run/jour, idempotent, relançable.

## B. Modèle de données (Drizzle côté app — le worker consomme en SQL brut)

```ts
export const gscQuerySnapshots = pgTable("gsc_query_snapshots", {
  siteId: text("site_id").notNull().references(() => sites.id, { onDelete: "cascade" }),
  date: date("date").notNull(),            // jour GSC (UTC)
  query: text("query").notNull(),
  clicks: integer("clicks").notNull(),
  impressions: integer("impressions").notNull(),
  ctr: real("ctr").notNull(),              // 0..1
  position: real("position").notNull(),
}, (t) => [primaryKey({ columns: [t.siteId, t.date, t.query] })]);

export const gscPageSnapshots = pgTable("gsc_page_snapshots", {
  siteId: text("site_id").notNull().references(() => sites.id, { onDelete: "cascade" }),
  date: date("date").notNull(),
  page: text("page").notNull(),            // URL canonique GSC
  clicks: integer("clicks").notNull(),
  impressions: integer("impressions").notNull(),
  ctr: real("ctr").notNull(),
  position: real("position").notNull(),
}, (t) => [primaryKey({ columns: [t.siteId, t.date, t.page] })]);

export const gscSnapshotRuns = pgTable("gsc_snapshot_runs", {
  id: text("id").primaryKey(),             // uuid généré par le worker
  startedAt: timestamp("started_at", { withTimezone: true }).notNull(),
  finishedAt: timestamp("finished_at", { withTimezone: true }),
  sitesOk: integer("sites_ok").notNull().default(0),
  sitesFailed: integer("sites_failed").notNull().default(0),
  rowsUpserted: integer("rows_upserted").notNull().default(0),
  error: text("error"),                    // première erreur rencontrée (résumé)
});
```

Migration via `db:generate` + `db:migrate` + `db:migrate:test` (jamais `db:push`). `gsc_snapshot_runs` rend visible « dernier relevé » (Settings→Intégrations, plus tard).

## C. Token broker — `/api/worker/gsc-token` (Next route handler)

- **POST** `{ siteId: string }`, header `Authorization: Bearer ${WORKER_API_KEY}`.
- Garde : `WORKER_API_KEY` (nouvel env, `z.string().optional()` dans `src/env.ts`) — absent ⇒ 503 ; header mismatch ⇒ 401. Comparaison en temps constant (`crypto.timingSafeEqual`).
- Résolution : `sites.id = siteId` → `gscProperty` (404 si null) → `member` where `organizationId = site.orgId AND role = 'owner'` (le connect GSC est owner-only) → `account` where `userId = owner.userId AND providerId = 'google'` (404 si absent) → `auth.api.getAccessToken({ body: { providerId: "google", userId: owner.userId } })` (signature vérifiée : `userId` optionnel supporté server-side ; BA gère refresh + déchiffrement `encryptOAuthTokens`).
- Réponse : `{ accessToken: string, property: string }` ; 502 si BA échoue.
- Module logique séparé du handler (`src/lib/gsc/worker-token.ts`, testable sans HTTP) ; le handler ne fait que parse/garde/délègue.

## D. Worker `crates/gsc-worker`

- **Deps** : `tokio` (rt-multi-thread, macros), `reqwest` (json, rustls-tls), `sqlx` (runtime-tokio, tls-rustls, postgres, chrono, uuid — requêtes **runtime**, pas de macros compile-time), `serde`/`serde_json`, `tracing` + `tracing-subscriber`, `chrono`, `uuid`. Install via `sfw cargo add`.
- **Env** : `DATABASE_URL`, `APP_URL` (ex. http://localhost:3000), `WORKER_API_KEY`. Optionnel : `GSC_WINDOW_DAYS` (défaut 5).
- **Run** :
  1. Insert `gsc_snapshot_runs` (started).
  2. `SELECT id, gsc_property FROM sites WHERE gsc_property IS NOT NULL`.
  3. Par site (séquentiel — volumes faibles, quotas GSC ménagés) : token via broker → pour chaque dimension (`query`, `page`) : POST `searchAnalytics/query` `{ startDate: J-5, endDate: J-1, dimensions: ["date", <dim>], rowLimit: 25000, startRow: <pagination> }` → parse lignes (`keys[0]`=date, `keys[1]`=query|page) → UPSERT batch `ON CONFLICT (site_id, date, <dim>) DO UPDATE SET clicks/impressions/ctr/position`.
  4. Erreur sur un site : log `tracing::error`, `sites_failed += 1`, continue.
  5. Update du run (finished, compteurs, première erreur éventuelle). Exit code ≠ 0 si tous les sites ont échoué.
- **Fenêtre J-5→J-1** : la donnée GSC a ~2-3 j de latence ; re-fetch + upsert = auto-correction des jours récents. Pas de J (toujours incomplet).
- **Pagination** : si `rows.len() == rowLimit`, re-requête avec `startRow += rowLimit` (boucle).

## E. Ops

- Dev : `cargo run -p gsc-worker` + script package.json `"worker:gsc": "cargo run -p gsc-worker"`. `.env.example`... (write-protected — documenter dans le README du crate : DATABASE_URL/APP_URL/WORKER_API_KEY ; l'utilisateur ajoute `WORKER_API_KEY` à `.env.local`). Le binaire charge l'env via variables process (cron exporte) — en dev, `bun --env-file=.env.local run` ne s'applique pas à cargo : fournir `worker:gsc` qui sourcera `.env.local` (script sh inline) ou documenter `env $(cat .env.local | grep -E '^(DATABASE_URL|WORKER_API_KEY)' ) APP_URL=... cargo run`.
- Prod : cron Dokploy quotidien (ex. 06:00 UTC) — **ajouté au runbook ELEVA-41** (env + binaire buildé dans l'image ou `cargo build --release` au build).
- Workspace : ajouter `"crates/gsc-worker"` aux members du Cargo.toml racine.

## F. Tests

- **Rust (unit)** : parse réponse searchAnalytics (fixture JSON → lignes typées, gestion keys[date,dim]) ; calcul fenêtre (J-5→J-1 en UTC) ; logique de pagination (rows==limit ⇒ continue). Pas d'appel réseau ni DB dans les units.
- **TS (unit/DB)** : `worker-token.ts` — résolution site→owner→account (DB test, pattern actions.test.ts) ; refus sans clé/mauvaise clé (handler) ; 404 sans gscProperty / sans compte google.
- **Migration** : `\d` des 3 tables.
- **Live** : `WORKER_API_KEY` ajouté à `.env.local` (action utilisateur), dev server up, `cargo run -p gsc-worker` → lignes datées dans `gsc_query_snapshots`/`gsc_page_snapshots` pour Acieb + 1 ligne `gsc_snapshot_runs` ok.

## G. Hors scope

Pages Stats (consommatrices — chantier suivant) · dimension **pays** (ELEVA-57) · queue/scheduler interne · multi-comptes Google par org · rétention/aggrégation long terme (on garde tout, volumes faibles).
