# GSC Connection + Property Mapping — Design (Slice 1)

- **Date**: 2026-06-05
- **Status**: approved (design), pending spec review
- **Section**: integrations / Google Search Console
- **Scope**: first GSC slice — connect Google Search Console and map a verified GSC property to each site. No analytics/data display yet.

## Goal

Let an organization connect Google Search Console and associate one verified GSC property with each of its sites, so later slices (performance dashboard, keyword enrichment) have a property to query.

## Decisions (from brainstorming)

- **Capability first**: connection + property↔site mapping only.
- **Placement**: a new `/settings/integrations` page (Settings → "Intégrations"), alongside Account and Team.
- **Connection model**: **org-level, owner-only**. Only the org **owner** can connect Google and edit mappings. The connection + mappings are **read-only for members** (diffused).
- **Identity (Approach A — derive from owner)**: no dedicated connection table. "Connected" = the org owner has a linked Google account carrying the `webmasters.readonly` scope. The GSC access token is resolved server-side from the owner's Google account. Only migration is `sites.gscProperty`.
- **API access**: raw GSC REST (no `googleapis` dependency).

## Non-goals (later slices)

- Search-analytics dashboard (clicks/impressions/CTR/position).
- Enriching the keywords reservoir with GSC queries.
- URL inspection / index coverage.
- Multiple GSC connections per org, or connection by non-owners.
- Sitemaps management.

## Prerequisites already in place

- Google social provider configured in `auth.ts` with `scope: ["https://www.googleapis.com/auth/webmasters.readonly"]`, `accessType: "offline"`, `prompt: "consent"` → an offline refresh token is obtained on Google sign-in/link.
- `account.encryptOAuthTokens: true` (tokens stored encrypted at rest).
- Roles owner/admin/staff; `getActiveOrgId()` / `getActiveUserId()` server helpers; org owner is the `member` row with `role = "owner"`.

## Architecture

```
/settings/integrations (server page)
  └─ resolves: orgId, current user role, owner's connection status, sites + their gscProperty, available GSC properties (if owner connected)
  └─ <GscIntegrations ... />  (client island)
        ├─ owner & not connected → "Connecter Google Search Console" → authClient.linkSocial(google, webmasters scope)
        ├─ owner & connected     → per-site Select of verified GSC property → setSiteGscProperty action
        └─ member                → read-only status + mappings

src/lib/gsc/
  ├─ client.ts   — REST helper: given an access token, GET https://www.googleapis.com/webmasters/v3/sites → SiteEntry[]
  ├─ token.ts    — getGscAccessToken(): auth.api.getAccessToken({ body: { providerId:"google" }, headers }) for the current session (the owner) → access token, or null if not connected. (Cross-user resolution via userId is deferred to the data-fetch slice.)
  └─ actions.ts  — server actions (owner-gated): listGscProperties(), setSiteGscProperty(siteId, property|null)
```

## Data flow

1. **Connect** (owner): `authClient.linkSocial({ provider: "google", scopes: ["https://www.googleapis.com/auth/webmasters.readonly"], callbackURL: "/settings/integrations" })`. Requires enabling `account.accountLinking` in `auth.ts` (trusted provider google). After linking, the owner's `account` row holds the refresh token.
2. **List properties**: server resolves the owner's access token via `auth.api.getAccessToken({ body: { providerId: "google", userId: ownerUserId } })` (verified: the endpoint body accepts an optional `userId`), then `GET /webmasters/v3/sites` with `Authorization: Bearer <token>` → returns `{ siteEntry: [{ siteUrl, permissionLevel }] }`. Filter to verified/owned properties.
3. **Map**: owner picks a property per site → `setSiteGscProperty(siteId, property)` writes `sites.gscProperty` (org-scoped; action re-checks the caller is owner of the site's org).
4. **Members**: the page renders connection status + each site's mapped property read-only.

## Data model

- Add column **`sites.gscProperty` `text` (nullable)** — the mapped GSC property string (e.g. `sc-domain:lustrelab.com` or `https://lustrelab.com/`). One per site. Drizzle migration via `db:generate` + apply to dev and `_test`.
- No connection table (Approach A): connection state is derived from the owner's linked Google account.

## Permissions

- `listGscProperties` and `setSiteGscProperty` re-check server-side that the current user is the **owner** of the active org; otherwise return an error. UI hides the controls for non-owners and shows read-only state.
- The token is always the **owner's**, regardless of who triggers a (future) read — but in this slice only the owner mutates; members only read stored mappings + a boolean "connected".

## Token resolution

**Slice 1 is simple**: only the **owner** triggers GSC calls (connect, list properties, map). So the token is resolved for the **current session user** (who is the owner) — `auth.api.getAccessToken({ body: { providerId: "google" }, headers })`. No cross-user access is needed in this slice; members never call the GSC API here (they only read the stored `sites.gscProperty`).

**Deferred to the data-fetch slice** (performance dashboard): when a *member* needs data fetched with the *owner's* token, resolve via `auth.api.getAccessToken({ body: { providerId: "google", userId: ownerUserId } })` — the body schema accepts an optional `userId`. The future plan must verify there's no session-equality guard; fallback = read the owner's `account` row, decrypt the refresh token (Better Auth symmetric decrypt with the secret), exchange at Google's token endpoint (`https://oauth2.googleapis.com/token`, `grant_type=refresh_token`).

- If the owner has no linked Google account → state = "not connected"; properties list is empty.

## Error handling

- Not connected (no owner Google account) → friendly "connecte Google Search Console" state (owner) / "l'admin n'a pas encore connecté GSC" (member).
- Token refresh failure / revoked grant → show "reconnecte Google" (owner).
- GSC API non-2xx → surface a neutral error; never crash the page.
- A site mapped to a property the owner no longer has access to → keep the stored value, flag it as "non vérifiée" on next list.

## Testing

- Unit: `client.ts` parsing of the `/sites` response (mock fetch) → SiteEntry[]; filtering verified properties.
- Unit: `setSiteGscProperty` validation (property belongs to the listed verified set; owner-only guard) — DB-gated test against `_test`.
- Manual (d3k): owner sees connect button → (mock/real) connected → per-site Select persists; member sees read-only.

## Open items for the plan

- Confirm `getAccessToken` server-side cross-user behavior (above).
- Decide property identifier display (domain vs URL-prefix properties).
- Nav: add "Intégrations" entry under Settings (sidebar `app-sidebar.tsx`).
