# Phase 2 — Multi-tenant migration of `es_site` / `es_site_test` Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Migrer `es_site` et `es_site_test` du modèle single-user vers le modèle multi-tenant — chaque site appartient à une `organization`, les queries domaine filtrent par `activeOrganizationId` au lieu de `userId`, et un nouveau guard `requireOrgSession()` formalise la résolution de l'org courante.

**Architecture:** On ajoute `organization_id` à `es_site` (NOT NULL, FK cascade) et `es_site_test` (NULLABLE — les audits anonymes restent sans org). On renomme `user_id` → `created_by_user_id` (sites) / `triggered_by_user_id` (tests) en les rendant nullables pour préserver le row si l'user est supprimé. Backfill SQL dans la migration : pour chaque row historique, lookup membership du user puis set `organization_id`. Refactor de toutes les queries domaine (11 routes API + 2 pages dashboard + 4 pages admin + 1 lib helper + 1 client hook) pour passer par `activeOrganizationId` venu de la session Better Auth.

**Tech Stack:** Next.js 16 App Router, Better Auth 1.6 (org plugin), Drizzle ORM 0.45, Postgres (brew local), tsx scripts.

**Spec source:** `docs/superpowers/specs/2026-05-25-org-roles-stripe-billing-design.md` §4.5, §4.6 (steps 3-7).
**Cartography:** `docs/superpowers/specs/2026-05-25-phase-2-cartography.md` (exhaustive file list).

**Decisions taken (utilisateur, 2026-05-25):**

- Anonymous audits (integration API) → `organization_id NULLABLE` (pas d'"Anonymous Org" synthétique).
- Cron audits → `triggered_by_user_id = NULL` (pas de user système fake).
- `/admin/sites` → afficher `organization.name` (drill-down vers owner email reporté à Phase 7).
- `/admin/users` sitesCount/testsCount → via membership JOIN (sites dans toutes les orgs de l'user).

**Hors-scope (différé) :**

- Org switcher UI (Phase 3).
- Settings team / RBAC guards complets (Phase 4) — on ajoute seulement `requireOrgSession()` ici.
- Stripe / quotas (Phases 5-6).
- Migration de la table `es_lead` (reste user-based, capture marketing avant org).

---

## File Structure

| Path                                               | Status         | Responsibility                                                                                                                           |
| -------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `lib/db/schema.ts`                                 | modify         | Ajout `organization_id` sur `es_site` + `es_site_test` ; rename `user_id` → `created_by_user_id` / `triggered_by_user_id` ; indexes.     |
| `drizzle/000X_*.sql`                               | generated+edit | Migration auto-générée par Drizzle, **complétée à la main** avec le SQL de backfill avant le `SET NOT NULL` sur `sites.organization_id`. |
| `lib/auth-guards.ts`                               | modify         | Ajoute `requireOrgSession()` qui résout l'org active depuis la session, redirige `/onboarding` si absente.                               |
| `lib/sitespeed-launch.ts`                          | modify         | `LaunchTestArgs` : remplace `userId` par `organizationId` + `triggeredByUserId` (les deux nullable).                                     |
| `app/api/sites/route.ts`                           | modify         | GET + POST + `launchFirstTest` helper.                                                                                                   |
| `app/api/sites/[siteId]/route.ts`                  | modify         | GET + DELETE.                                                                                                                            |
| `app/api/sites/[siteId]/test/route.ts`             | modify         | POST (insert) + GET (vérif appartenance org via siteId join).                                                                            |
| `app/api/sites/[siteId]/har/route.ts`              | modify         | GET filter.                                                                                                                              |
| `app/api/integrations/audit/route.ts`              | modify         | Dedupe + insert anonyme (`organizationId = null`, `triggeredByUserId = null`).                                                           |
| `app/api/integrations/audit/[id]/route.ts`         | modify         | Filter `triggeredByUserId IS NULL`.                                                                                                      |
| `app/api/cron/run-audits/route.ts`                 | modify         | Passer `organizationId: site.organizationId`, `triggeredByUserId: null` à `launchSitespeedTest`.                                         |
| `app/[locale]/dashboard/sites/[siteId]/page.tsx`   | modify         | Swap filter.                                                                                                                             |
| `app/[locale]/dashboard/sites/[siteId]/actions.ts` | modify         | Swap filter.                                                                                                                             |
| `app/[locale]/admin/sites/page.tsx`                | modify         | `leftJoin(user)` → `leftJoin(organization)`, afficher `organization.name`.                                                               |
| `app/[locale]/admin/users/page.tsx`                | modify         | Refactor sitesCount/testsCount subqueries via membership JOIN.                                                                           |
| `app/[locale]/admin/tests/page.tsx`                | inspect        | Probablement no-op (join par siteId, pas par user). Vérifier.                                                                            |
| `app/[locale]/admin/page.tsx`                      | inspect        | No-op (counts globaux).                                                                                                                  |
| `data/sites/hooks.ts`                              | modify         | Type `SiteWithLatest` : ajouter `organizationId`, retirer `userId` (rename `createdByUserId` si client en a besoin).                     |
| `scripts/verify-org-sites.ts`                      | create         | Smoke test post-migration : signup → add site → assert `organization_id` matches active org → cleanup.                                   |

---

## Task 1: Preflight — DB state and assumptions check

**Files:** none modified

- [ ] **Step 1.1: Verify Phase 1 state is in place**

```bash
cd /Users/humperdoo/Github/eleva-speed
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c '\dt'
```

Expected: 10 tables present including `organization`, `member`, `invitation`. Don't print the password.

- [ ] **Step 1.2: Confirm every user has a membership**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT u.id FROM \"user\" u LEFT JOIN member m ON m.user_id = u.id WHERE m.id IS NULL;"
```

Expected: `(0 rows)`. If any user has no membership, run `pnpm db:backfill-orgs` first.

- [ ] **Step 1.3: Count existing rows we'll backfill**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT (SELECT COUNT(*) FROM es_site) AS sites, (SELECT COUNT(*) FROM es_site_test) AS tests, (SELECT COUNT(*) FROM es_site_test WHERE user_id IS NULL) AS anon_tests;"
```

Note the numbers — the backfill SQL in step 3.x must touch exactly those rows (minus anonymous tests).

- [ ] **Step 1.4: Confirm test users exist**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT email FROM \"user\" WHERE email IN ('owner-test@elevaseo.com', 'admin-test@elevaseo.com', 'member-test@elevaseo.com');"
```

Expected: 3 rows. If missing, run `pnpm db:seed-test-users`.

---

## Task 2: Schema additions in `lib/db/schema.ts`

**Files:**

- Modify: `lib/db/schema.ts` (sites + siteTests blocks)

- [ ] **Step 2.1: Update `sites` table definition**

Replace the entire `sites` block at lines 144-164 with:

```ts
export const sites = pgTable(
  "es_site",
  {
    id: text("id").primaryKey(),
    organizationId: text("organization_id")
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    createdByUserId: text("created_by_user_id").references(() => user.id, {
      onDelete: "set null",
    }),
    hostname: text("hostname").notNull(),
    url: text("url").notNull(),
    label: text("label"),
    createdAt: timestamp("created_at", { withTimezone: true, mode: "string" })
      .notNull()
      .defaultNow(),
    // Scheduled audits (cron driven from pg_cron or host cron).
    auditCadence: text("audit_cadence").$type<AuditCadence>().notNull().default("none"),
    lastAuditAt: timestamp("last_audit_at", { withTimezone: true, mode: "string" }),
    nextAuditAt: timestamp("next_audit_at", { withTimezone: true, mode: "string" }),
  },
  (table) => [
    index("idx_site_org").on(table.organizationId),
    index("idx_site_next_audit").on(table.nextAuditAt),
  ],
);
```

Changes vs current:

- `userId: text("user_id").notNull()` → REPLACED by two columns: `organizationId` (NOT NULL, FK cascade to org) + `createdByUserId` (nullable, FK set null to user).
- Index `idx_site_user` → `idx_site_org`.

- [ ] **Step 2.2: Update `siteTests` table definition**

Replace the entire `siteTests` block at lines 166-226 with:

```ts
export const siteTests = pgTable(
  "es_site_test",
  {
    id: text("id").primaryKey(),
    siteId: text("site_id").references(() => sites.id, { onDelete: "cascade" }),
    organizationId: text("organization_id").references(() => organization.id, {
      onDelete: "cascade",
    }),
    triggeredByUserId: text("triggered_by_user_id").references(() => user.id, {
      onDelete: "set null",
    }),
    hostname: text("hostname"),
    url: text("url").notNull(),
    device: text("device"), // "desktop" | "mobile"
    status: text("status").notNull(),
    browser: text("browser"),
    iterations: integer("iterations"),
    error: text("error"),
    createdAt: timestamp("created_at", { withTimezone: true, mode: "string" })
      .notNull()
      .defaultNow(),
    completedAt: timestamp("completed_at", { withTimezone: true, mode: "string" }),

    // Core Web Vitals + metrics
    fcp: real("fcp"),
    lcp: real("lcp"),
    tbt: real("tbt"),
    cls: real("cls"),
    ttfb: real("ttfb"),
    fid: real("fid"),
    si: real("si"),
    fullyLoaded: real("fully_loaded"),
    domContentLoaded: real("dom_content_loaded"),
    domInteractive: real("dom_interactive"),
    requests: integer("requests"),
    transferSize: integer("transfer_size"),

    // Lighthouse scores
    lighthousePerf: integer("lighthouse_perf"),
    lighthouseA11y: integer("lighthouse_a11y"),
    lighthouseBp: integer("lighthouse_bp"),
    lighthouseSeo: integer("lighthouse_seo"),

    // Lighthouse data (native jsonb — typed via @/lib/sitespeed)
    lighthouseOpportunities: jsonb("lighthouse_opportunities").$type<LighthouseOpportunity[]>(),
    lighthouseDiagnostics: jsonb("lighthouse_diagnostics").$type<LighthouseDiagnostic[]>(),
    resourceBreakdown: jsonb("resource_breakdown").$type<ResourceBreakdown>(),

    // Computed (from our scoring, kept for backward compat)
    performanceScore: integer("performance_score"),

    // Paths from API
    reportUrl: text("report_url"),
    lighthouseReportUrl: text("lighthouse_report_url"),
    harUrl: text("har_url"),
    screenshotAfter: text("screenshot_after"),
    screenshotLcp: text("screenshot_lcp"),
    screenshotCls: text("screenshot_cls"),
  },
  (table) => [
    index("idx_site_test_site").on(table.siteId),
    index("idx_site_test_org").on(table.organizationId),
    index("idx_site_test_created").on(table.createdAt),
    index("idx_site_test_hostname").on(table.hostname),
  ],
);
```

Changes vs current:

- `userId: text("user_id")` → REPLACED by two columns: `organizationId` (NULLABLE — anon audits) + `triggeredByUserId` (NULLABLE — cron / anon / set-null on user delete).
- Index `idx_site_test_user` → `idx_site_test_org`.

- [ ] **Step 2.3: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | tail -10
```

Expected: errors about missing `userId` on `sites`/`siteTests` in lots of files. **Don't fix them yet** — they'll be addressed in tasks 6-13. The typecheck errors here are EXPECTED and serve as a punch list.

If errors include anything OTHER than "Property 'userId' does not exist on type" or "Object literal may only specify known properties, and 'userId' does not exist": stop and report.

---

## Task 3: Generate migration + add backfill SQL

**Files:**

- Generated: `drizzle/000X_*.sql`
- Edit: same file (insert backfill SQL between column-add and NOT-NULL-set)

- [ ] **Step 3.1: Generate the migration**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm db:generate
```

Expected: a new file `drizzle/0005_*.sql` is created. Read it. It should contain (in some order):

- `ALTER TABLE "es_site" ADD COLUMN "organization_id" text NOT NULL;` (Drizzle generates this — we'll need to defer the NOT NULL)
- `ALTER TABLE "es_site" ADD COLUMN "created_by_user_id" text;`
- `ALTER TABLE "es_site" DROP COLUMN "user_id";`
- `ALTER TABLE "es_site_test" ADD COLUMN "organization_id" text;`
- `ALTER TABLE "es_site_test" ADD COLUMN "triggered_by_user_id" text;`
- `ALTER TABLE "es_site_test" DROP COLUMN "user_id";`
- FK constraints + indexes (drop old, add new).

**Note:** Drizzle DROPs `user_id` instead of RENAMing it because Drizzle can't infer that an old name (`user_id`) maps to a new name (`created_by_user_id`/`triggered_by_user_id`). We will manually edit the SQL to RENAME + preserve data. The Drizzle-generated SQL would lose all historical user attribution.

- [ ] **Step 3.2: Manually edit the migration to preserve `user_id` data**

Open `drizzle/0005_*.sql`. Replace the auto-generated DDL with the following **ordered** SQL (idempotent where possible, wrapped logically — Drizzle migrations run as a single transaction per file):

```sql
-- 1. Rename existing user_id columns to preserve historical data
ALTER TABLE "es_site" RENAME COLUMN "user_id" TO "created_by_user_id";
ALTER TABLE "es_site_test" RENAME COLUMN "user_id" TO "triggered_by_user_id";

-- 2. Drop the old NOT NULL constraint on es_site.created_by_user_id (was user_id NOT NULL)
ALTER TABLE "es_site" ALTER COLUMN "created_by_user_id" DROP NOT NULL;

-- 3. Add FK constraints with ON DELETE SET NULL
ALTER TABLE "es_site" ADD CONSTRAINT "es_site_created_by_user_id_user_id_fk"
  FOREIGN KEY ("created_by_user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
ALTER TABLE "es_site_test" ADD CONSTRAINT "es_site_test_triggered_by_user_id_user_id_fk"
  FOREIGN KEY ("triggered_by_user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION;

-- 4. Add organization_id columns (NULLABLE for now so we can backfill)
ALTER TABLE "es_site" ADD COLUMN "organization_id" text;
ALTER TABLE "es_site_test" ADD COLUMN "organization_id" text;

-- 5. Backfill organization_id from the user's first membership.
--    For sites: every existing site has created_by_user_id set (was NOT NULL before).
--    For tests: only tests with a non-null triggered_by_user_id get backfilled.
--    Anonymous tests keep organization_id = NULL (intended).
UPDATE "es_site" s
SET "organization_id" = (
  SELECT m."organization_id"
  FROM "member" m
  WHERE m."user_id" = s."created_by_user_id"
  ORDER BY m."created_at" ASC
  LIMIT 1
)
WHERE s."created_by_user_id" IS NOT NULL;

UPDATE "es_site_test" t
SET "organization_id" = (
  SELECT m."organization_id"
  FROM "member" m
  WHERE m."user_id" = t."triggered_by_user_id"
  ORDER BY m."created_at" ASC
  LIMIT 1
)
WHERE t."triggered_by_user_id" IS NOT NULL;

-- 6. Verify no orphan sites (every site MUST have organization_id now).
--    If this raises, it means a site existed with NULL or unknown user_id and we
--    have no way to assign it. Investigate before continuing.
DO $$
DECLARE orphan_count int;
BEGIN
  SELECT COUNT(*) INTO orphan_count FROM "es_site" WHERE "organization_id" IS NULL;
  IF orphan_count > 0 THEN
    RAISE EXCEPTION 'Backfill incomplete: % es_site row(s) still have NULL organization_id', orphan_count;
  END IF;
END $$;

-- 7. Set NOT NULL on es_site.organization_id + add FK
ALTER TABLE "es_site" ALTER COLUMN "organization_id" SET NOT NULL;
ALTER TABLE "es_site" ADD CONSTRAINT "es_site_organization_id_organization_id_fk"
  FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION;

-- 8. es_site_test.organization_id stays NULLABLE (anonymous audits) — just add the FK
ALTER TABLE "es_site_test" ADD CONSTRAINT "es_site_test_organization_id_organization_id_fk"
  FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE NO ACTION;

-- 9. Drop old indexes, add new ones
DROP INDEX IF EXISTS "idx_site_user";
DROP INDEX IF EXISTS "idx_site_test_user";
CREATE INDEX "idx_site_org" ON "es_site" USING btree ("organization_id");
CREATE INDEX "idx_site_test_org" ON "es_site_test" USING btree ("organization_id");
```

Save the file. Verify the journal entry (`drizzle/meta/_journal.json`) and snapshot (`drizzle/meta/0005_snapshot.json`) are still generated by Drizzle — they describe the END state, which matches our manual SQL.

- [ ] **Step 3.3: Apply the migration**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm db:migrate 2>&1 | tail -15
```

Expected: `[✓] migrations applied successfully!`. If the `DO $$` block raises (step 6 in the SQL), it means some site has `created_by_user_id` that doesn't match any member — investigate before continuing.

- [ ] **Step 3.4: Verify post-migration DB state**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c '\d es_site'
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c '\d es_site_test'
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT COUNT(*) FROM es_site WHERE organization_id IS NULL;"
```

Don't print the password. Expected:

- `es_site` has columns: `id`, `organization_id` (NOT NULL), `created_by_user_id` (nullable), `hostname`, `url`, `label`, `created_at`, `audit_cadence`, `last_audit_at`, `next_audit_at`. Index `idx_site_org` present, `idx_site_user` absent.
- `es_site_test` has columns including `organization_id` (NULLABLE), `triggered_by_user_id` (NULLABLE), no `user_id`. Index `idx_site_test_org` present.
- `SELECT COUNT(*) FROM es_site WHERE organization_id IS NULL` = `0`.

- [ ] **Step 3.5: Commit schema + migration**

```bash
git add lib/db/schema.ts drizzle/0005_*.sql drizzle/meta/
git commit -m "feat(db): add organization_id + rename user_id on es_site / es_site_test

Multi-tenant migration of the domain tables:
- es_site: organization_id NOT NULL (FK cascade), user_id renamed to
  created_by_user_id (NULLABLE, FK set null) for audit trail.
- es_site_test: organization_id NULLABLE (anonymous audits stay NULL),
  user_id renamed to triggered_by_user_id (NULLABLE: cron + anon + set null).
- Indexes idx_site_org / idx_site_test_org replace idx_site_user / idx_site_test_user.
- Backfill SQL in the migration derives organization_id from each row's
  user_id via the user's earliest membership; aborts the transaction if
  any site row would end up NULL."
```

---

## Task 4: Add `requireOrgSession()` helper to `lib/auth-guards.ts`

**Files:**

- Modify: `lib/auth-guards.ts`

- [ ] **Step 4.1: Read the current state of `lib/auth-guards.ts`**

The file currently exports `requireAdmin()`. We're adding `requireOrgSession()` alongside it. Read the file to confirm imports and the existing pattern.

- [ ] **Step 4.2: Add `requireOrgSession()` function**

Replace the entire contents of `lib/auth-guards.ts` with:

```ts
import { headers } from "next/headers";

import { redirect } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
import { auth } from "@/lib/auth";

/* Returns the session if the user is signed in AND has role === "admin".
   Redirects to /login if unauthenticated, to /dashboard if authenticated
   but not admin. */
export async function requireAdmin(locale: Locale = routing.defaultLocale) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect({ href: "/login", locale });

  // Better Auth admin plugin extends user with `role`.
  const role = (session!.user as { role?: string | null }).role;
  if (role !== "admin") redirect({ href: "/dashboard", locale });

  return session!;
}

/* Returns { session, activeOrganizationId } for a signed-in user with an
   active org. Redirects to /login if unauthenticated, /onboarding if no
   active org is set (defensive — Phase 1 guarantees every user has at
   least one Personal Org, so this should only fire for racing edge cases). */
export async function requireOrgSession(locale: Locale = routing.defaultLocale) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect({ href: "/login", locale });

  // Better Auth org plugin extends session with activeOrganizationId.
  const activeOrganizationId = (session!.session as { activeOrganizationId?: string | null })
    .activeOrganizationId;
  if (!activeOrganizationId) redirect({ href: "/onboarding", locale });

  return { session: session!, activeOrganizationId: activeOrganizationId! };
}
```

- [ ] **Step 4.3: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | tail -5
```

Expected: still errors about `userId` in other files (unchanged from Task 2.3). No NEW errors in `lib/auth-guards.ts`.

- [ ] **Step 4.4: Commit**

```bash
git add lib/auth-guards.ts
git commit -m "feat(auth): add requireOrgSession() guard for org-scoped routes

Returns { session, activeOrganizationId }. Redirects /login if unauth,
/onboarding if no active org (defensive — every signup gets a Personal
Org via the Phase 1 hook). To be consumed by all org-scoped routes
refactored in the next commits."
```

---

## Task 5: Refactor `lib/sitespeed-launch.ts`

**Files:**

- Modify: `lib/sitespeed-launch.ts`

- [ ] **Step 5.1: Update `LaunchTestArgs` + insert payload**

Replace the entire file contents with:

```ts
import type { Device } from "@/lib/sitespeed";

import { db } from "@/lib/db";
import { siteTests } from "@/lib/db/schema";

const SITESPEED_API_URL = process.env.SITESPEED_API_URL || "https://speed.elevaseo.app";
const SITESPEED_API_KEY = process.env.SITESPEED_API_KEY || "";

export type LaunchTestArgs = {
  url: string;
  hostname: string;
  device: Device;
  siteId: string | null;
  /* Owning org. NULL only for anonymous audits triggered via the public
     integration API (no auth). All authenticated paths must pass an org. */
  organizationId: string | null;
  /* User who triggered the test. NULL for cron (no user) and anonymous
     audits. Set to user.id for manual user-triggered runs. */
  triggeredByUserId: string | null;
};

export type LaunchTestResult =
  | { ok: true; id: string; status: string }
  | { ok: false; status: number; error: string };

export async function launchSitespeedTest(args: LaunchTestArgs): Promise<LaunchTestResult> {
  const res = await fetch(`${SITESPEED_API_URL}/api/test`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SITESPEED_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url: args.url, device: args.device }),
  });
  if (!res.ok) {
    const data = await res.json().catch(() => ({}));
    return {
      ok: false,
      status: res.status,
      error: data.error || `Sitespeed API returned ${res.status}`,
    };
  }
  const { id, status } = await res.json();
  await db.insert(siteTests).values({
    id,
    siteId: args.siteId,
    organizationId: args.organizationId,
    triggeredByUserId: args.triggeredByUserId,
    hostname: args.hostname,
    url: args.url,
    device: args.device,
    status,
    createdAt: new Date().toISOString(),
  });
  return { ok: true, id, status };
}
```

- [ ] **Step 5.2: Typecheck — `lib/sitespeed-launch.ts` itself should now be clean**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep -E "sitespeed-launch|cron/run-audits" | head -10
```

Expected: only `cron/run-audits/route.ts` errors remain (caller still passes `userId:`), not `sitespeed-launch.ts` itself.

- [ ] **Step 5.3: Commit**

```bash
git add lib/sitespeed-launch.ts
git commit -m "refactor(sitespeed): replace userId arg with organizationId + triggeredByUserId

Both new fields are nullable in the type to support all three flows:
- authenticated manual run: organizationId = activeOrg, triggeredByUserId = user.id
- authenticated cron run: organizationId = site.organizationId, triggeredByUserId = null
- public anonymous audit: organizationId = null, triggeredByUserId = null

Callers in app/api/sites/* + cron/run-audits will be updated in the next commits."
```

---

## Task 6: Refactor `app/api/sites/route.ts` (GET + POST + launchFirstTest)

**Files:**

- Modify: `app/api/sites/route.ts`

- [ ] **Step 6.1: Replace the file contents**

Replace the entire file with:

```ts
import type { Device } from "@/lib/sitespeed";

import { eq, desc } from "drizzle-orm";
import { nanoid } from "nanoid";
import { NextResponse } from "next/server";

import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { sites, siteTests } from "@/lib/db/schema";
import { resolveReachableUrl } from "@/lib/url";

const SITESPEED_API_URL = process.env.SITESPEED_API_URL || "https://speed.elevaseo.app";
const SITESPEED_API_KEY = process.env.SITESPEED_API_KEY || "";

async function launchFirstTest(args: {
  url: string;
  hostname: string;
  siteId: string;
  organizationId: string;
  triggeredByUserId: string;
  device: Device;
}) {
  const res = await fetch(`${SITESPEED_API_URL}/api/test`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SITESPEED_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url: args.url, device: args.device }),
  });
  if (!res.ok) return null;
  const { id, status } = await res.json();
  await db.insert(siteTests).values({
    id,
    siteId: args.siteId,
    organizationId: args.organizationId,
    triggeredByUserId: args.triggeredByUserId,
    hostname: args.hostname,
    url: args.url,
    device: args.device,
    status,
    createdAt: new Date().toISOString(),
  });
  return { id, status };
}

function getActiveOrgId(session: Awaited<ReturnType<typeof auth.api.getSession>>): string | null {
  const value = (session?.session as { activeOrganizationId?: string | null } | undefined)
    ?.activeOrganizationId;
  return value ?? null;
}

// GET /api/sites — list sites of the user's active org with latest metrics
export async function GET(req: Request) {
  const session = await auth.api.getSession({ headers: req.headers });
  if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  const orgId = getActiveOrgId(session);
  if (!orgId) return NextResponse.json({ error: "No active organization" }, { status: 400 });

  const orgSites = await db
    .select()
    .from(sites)
    .where(eq(sites.organizationId, orgId))
    .orderBy(desc(sites.createdAt));

  // For each site, get the latest completed test
  const result = await Promise.all(
    orgSites.map(async (site) => {
      const latestTest = (
        await db
          .select()
          .from(siteTests)
          .where(eq(siteTests.siteId, site.id))
          .orderBy(desc(siteTests.createdAt))
          .limit(1)
      )[0];

      const recentTests = (
        await db
          .select({
            performanceScore: siteTests.performanceScore,
            createdAt: siteTests.createdAt,
          })
          .from(siteTests)
          .where(eq(siteTests.siteId, site.id))
          .orderBy(desc(siteTests.createdAt))
          .limit(7)
      ).toReversed();

      return { ...site, latestTest: latestTest ?? null, sparkline: recentTests };
    }),
  );

  return NextResponse.json(result);
}

// POST /api/sites — add a new site to the user's active org
export async function POST(req: Request) {
  const session = await auth.api.getSession({ headers: req.headers });
  if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  const orgId = getActiveOrgId(session);
  if (!orgId) return NextResponse.json({ error: "No active organization" }, { status: 400 });

  const { url } = await req.json();
  if (!url || typeof url !== "string") {
    return NextResponse.json({ error: "URL requise" }, { status: 400 });
  }

  let fullUrl: string;
  let hostname: string;
  try {
    const resolved = await resolveReachableUrl(url);
    fullUrl = resolved.url;
    hostname = resolved.hostname;
  } catch (err) {
    return NextResponse.json(
      { error: err instanceof Error ? err.message : "URL invalide" },
      { status: 400 },
    );
  }

  // Check if site already exists for this org
  const existing = (await db.select().from(sites).where(eq(sites.organizationId, orgId))).find(
    (s) => s.hostname === hostname,
  );

  if (existing) {
    return NextResponse.json(existing);
  }

  const id = nanoid();
  const site = {
    id,
    organizationId: orgId,
    createdByUserId: session.user.id,
    hostname,
    url: fullUrl,
    label: null,
    createdAt: new Date().toISOString(),
  };
  await db.insert(sites).values(site);

  // Fire-and-await the first audit so the destination page's SSR picks it up.
  // Failure here doesn't roll back the site — user can re-launch manually.
  const firstTest = await launchFirstTest({
    url: fullUrl,
    hostname,
    siteId: id,
    organizationId: orgId,
    triggeredByUserId: session.user.id,
    device: "desktop",
  });

  return NextResponse.json({ ...site, firstTest }, { status: 201 });
}
```

- [ ] **Step 6.2: Typecheck the file**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep "api/sites/route.ts" | head -5
```

Expected: 0 errors on this file.

- [ ] **Step 6.3: Commit**

```bash
git add app/api/sites/route.ts
git commit -m "refactor(api/sites): scope GET/POST + first test by activeOrganizationId

GET lists the active org's sites instead of the user's. POST inserts with
organizationId + createdByUserId. launchFirstTest forwards organizationId
+ triggeredByUserId to siteTests. 401 when unauth, 400 when no active org."
```

---

## Task 7: Refactor `app/api/sites/[siteId]/route.ts` + `har/route.ts` + `test/route.ts`

**Files:**

- Modify: `app/api/sites/[siteId]/route.ts`
- Modify: `app/api/sites/[siteId]/har/route.ts`
- Modify: `app/api/sites/[siteId]/test/route.ts`

- [ ] **Step 7.1: Refactor `[siteId]/route.ts`**

Open the file. Replace every `eq(sites.userId, session.user.id)` with `eq(sites.organizationId, orgId)`. Add an org-id resolver at the top of each handler:

```ts
const orgId = (session.session as { activeOrganizationId?: string | null }).activeOrganizationId;
if (!orgId) return NextResponse.json({ error: "No active organization" }, { status: 400 });
```

right after the unauthenticated check. Both GET (line 19) and DELETE (line 42) need this. No other change.

- [ ] **Step 7.2: Refactor `[siteId]/har/route.ts` similarly**

Same pattern: add `orgId` resolver, replace the `sites.userId` filter.

- [ ] **Step 7.3: Refactor `[siteId]/test/route.ts`**

For the POST handler (line 15-63):

- Add `orgId` resolver.
- Replace `eq(sites.userId, session.user.id)` (line 27) with `eq(sites.organizationId, orgId)`.
- In the `db.insert(siteTests).values({...})` block around lines 52-60, replace `userId: session.user.id` with:

```ts
organizationId: orgId,
triggeredByUserId: session.user.id,
```

For the GET handler (line 65-133): no filter change needed (updates by id), but **add an authorization check**: before the update, verify the test belongs to a site in the user's active org:

```ts
const test = await db
  .select({ siteId: siteTests.siteId, organizationId: siteTests.organizationId })
  .from(siteTests)
  .where(eq(siteTests.id, testId))
  .limit(1);
if (test.length === 0 || test[0]!.organizationId !== orgId) {
  return NextResponse.json({ error: "Not found" }, { status: 404 });
}
```

- [ ] **Step 7.4: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep "api/sites/" | head -10
```

Expected: 0 errors on these files.

- [ ] **Step 7.5: Commit**

```bash
git add app/api/sites/[siteId]/
git commit -m "refactor(api/sites/[siteId]): scope detail + DELETE + test endpoints by orgId

Adds active-org resolution + 400 fallback on every handler. Filters now go
through sites.organizationId instead of sites.userId. The POST /test inserts
include organizationId + triggeredByUserId. The GET /test now checks the
test belongs to the caller's active org before allowing the update."
```

---

## Task 8: Refactor `app/api/integrations/audit/*` (anonymous audits)

**Files:**

- Modify: `app/api/integrations/audit/route.ts`
- Modify: `app/api/integrations/audit/[id]/route.ts`

- [ ] **Step 8.1: Refactor `audit/route.ts`**

Two changes:

1. The dedupe query at lines 52-56 uses `isNull(siteTests.userId)`. Rename to `isNull(siteTests.triggeredByUserId)`:

```ts
const existing = await db
  .select({ id: siteTests.id, status: siteTests.status })
  .from(siteTests)
  .where(and(eq(siteTests.hostname, hostname), isNull(siteTests.triggeredByUserId)))
  .limit(1);
```

2. The insert at lines 90-98. Replace with:

```ts
await db.insert(siteTests).values({
  id,
  url: fullUrl,
  hostname,
  device,
  status,
  createdAt: new Date().toISOString(),
  // siteId, organizationId, triggeredByUserId all stay null — anonymous audit.
});
```

(No `userId: null` line — the column doesn't exist anymore.)

- [ ] **Step 8.2: Refactor `audit/[id]/route.ts`**

Change the filter at line 28 from `isNull(siteTests.userId)` to `isNull(siteTests.triggeredByUserId)`. No other change.

- [ ] **Step 8.3: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep "integrations" | head -5
```

Expected: 0 errors.

- [ ] **Step 8.4: Commit**

```bash
git add app/api/integrations/audit/
git commit -m "refactor(api/integrations): anonymous audits keep organizationId = null

Renames the dedupe + lookup filter from siteTests.userId to
siteTests.triggeredByUserId. Anonymous-audit rows now persist with
both organizationId AND triggeredByUserId = NULL (column was renamed).
No behavior change for the public API contract."
```

---

## Task 9: Refactor `app/api/cron/run-audits/route.ts`

**Files:**

- Modify: `app/api/cron/run-audits/route.ts`

- [ ] **Step 9.1: Update the `launchSitespeedTest` call**

At lines 57-63, replace the call args with:

```ts
const launched = await launchSitespeedTest({
  url: site.url,
  hostname: site.hostname,
  device: "desktop",
  siteId: site.id,
  organizationId: site.organizationId,
  triggeredByUserId: null,
});
```

Removed: `userId: site.userId`. Added: `organizationId: site.organizationId`, `triggeredByUserId: null`.

- [ ] **Step 9.2: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep "cron/run-audits" | head -5
```

Expected: 0 errors.

- [ ] **Step 9.3: Commit**

```bash
git add app/api/cron/run-audits/route.ts
git commit -m "refactor(api/cron): scheduled audits pass organizationId + null triggeredByUserId

Cron has no user to attribute the test to, so triggeredByUserId stays NULL.
The site's organization_id is forwarded to the launched test (each audit
belongs to the owning org, never to a user)."
```

---

## Task 10: Refactor dashboard pages

**Files:**

- Modify: `app/[locale]/dashboard/sites/[siteId]/page.tsx`
- Modify: `app/[locale]/dashboard/sites/[siteId]/actions.ts`

- [ ] **Step 10.1: Refactor `dashboard/sites/[siteId]/page.tsx`**

At line 26, replace:

```ts
.where(and(eq(sites.id, siteId), eq(sites.userId, session!.user.id)))
```

with:

```ts
.where(and(eq(sites.id, siteId), eq(sites.organizationId, orgId)))
```

Add `orgId` resolution at the top of the Server Component, replacing the existing session/redirect dance with `requireOrgSession()`:

```ts
import { requireOrgSession } from "@/lib/auth-guards";

// In the Server Component body, replace:
//   const session = await auth.api.getSession({ headers: await headers() });
//   if (!session) redirect(...);
// with:
const { session, activeOrganizationId: orgId } = await requireOrgSession(locale);
```

- [ ] **Step 10.2: Refactor `dashboard/sites/[siteId]/actions.ts`**

Replace the `sites.userId` filter at line 38 with `sites.organizationId`. Add the org id resolver inline (Server Actions don't have a clean way to use the guards since they're not in a layout — read session directly):

```ts
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const orgId = (session.session as { activeOrganizationId?: string | null }).activeOrganizationId;
if (!orgId) throw new Error("No active organization");
```

then:

```ts
.where(and(eq(sites.id, siteId), eq(sites.organizationId, orgId)));
```

- [ ] **Step 10.3: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep "dashboard/sites" | head -5
```

Expected: 0 errors.

- [ ] **Step 10.4: Commit**

```bash
git add app/[locale]/dashboard/sites/
git commit -m "refactor(dashboard/sites): scope detail page + actions by activeOrganizationId

The detail page uses requireOrgSession() for clean session+org resolution.
The Server Action resolves org inline (no layout context). Filters go
through sites.organizationId instead of sites.userId."
```

---

## Task 11: Refactor admin pages

**Files:**

- Modify: `app/[locale]/admin/sites/page.tsx`
- Modify: `app/[locale]/admin/users/page.tsx`
- Inspect: `app/[locale]/admin/tests/page.tsx`
- Inspect: `app/[locale]/admin/page.tsx`

- [ ] **Step 11.1: Refactor `admin/sites/page.tsx`**

Two changes:

1. Import `organization` from schema.
2. Replace the `leftJoin(user, eq(sites.userId, user.id))` (line 35) with `leftJoin(organization, eq(sites.organizationId, organization.id))`.
3. In the select projection at line 23, replace `ownerEmail: user.email` with `orgName: organization.name`.
4. Update the JSX cell that renders the owner column to render the org name (look for the column rendering `ownerEmail` in the table body and rename).

- [ ] **Step 11.2: Refactor `admin/users/page.tsx`**

The current subqueries at lines 26-31 count `sites.userId = user.id` and `siteTests.userId = user.id`. With the column gone, change them to count via membership JOIN. Replace the subqueries with:

```ts
// Sites count: number of sites in any org the user is a member of.
sitesCount: sql<number>`
  SELECT COUNT(DISTINCT ${sites.id})::int
  FROM ${sites}
  INNER JOIN ${member} m ON m.organization_id = ${sites.organizationId}
  WHERE m.user_id = ${user.id}
`.as("sites_count"),
// Tests count: number of tests in any org the user is a member of.
testsCount: sql<number>`
  SELECT COUNT(DISTINCT ${siteTests.id})::int
  FROM ${siteTests}
  INNER JOIN ${member} m ON m.organization_id = ${siteTests.organizationId}
  WHERE m.user_id = ${user.id} AND ${siteTests.organizationId} IS NOT NULL
`.as("tests_count"),
```

Make sure `member` is imported from `@/lib/db/schema`.

- [ ] **Step 11.3: Inspect `admin/tests/page.tsx`**

Read the file. The current query joins `siteTests` → `sites` by `siteId`. No userId reference. Confirm it still type-checks after Task 2 (since `siteTests.userId` is gone). If the JSX or projection references `userId`, drop those references — admin tests can show `triggeredByUserId` (now nullable, so `null` becomes "system" in the rendering).

- [ ] **Step 11.4: Inspect `admin/page.tsx`**

Read the file. The query `db.select({ value: count() }).from(siteTests)` (line 12) doesn't reference userId. Should still work unchanged. Confirm.

- [ ] **Step 11.5: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | grep "admin/" | head -10
```

Expected: 0 errors.

- [ ] **Step 11.6: Commit**

```bash
git add app/[locale]/admin/
git commit -m "refactor(admin): pivot sites/users/tests views to organization-based joins

admin/sites: shows organization.name instead of user.email (FK swap on
sites.organization_id). admin/users: sitesCount + testsCount now count
across all orgs the user is a member of, not direct ownership.
admin/tests + admin/page: verified unchanged (joins by siteId, no userId)."
```

---

## Task 12: Refactor `data/sites/hooks.ts` (client types)

**Files:**

- Modify: `data/sites/hooks.ts`

- [ ] **Step 12.1: Read the file**

Read `data/sites/hooks.ts` and find the `SiteWithLatest` and `SiteDetail` interfaces (around lines 9-51 per the cartography).

- [ ] **Step 12.2: Update both interfaces**

In `SiteWithLatest`:

- Remove `userId: string`.
- Add `organizationId: string`.
- Add `createdByUserId: string | null` (audit trail, client can show "Added by …" if it ever needs to).

In `SiteDetail` (if present): same changes.

The exact line numbers depend on the file. Make the changes by reading the file and editing the interface bodies.

- [ ] **Step 12.3: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | tail -5
```

Expected: 0 errors across the whole project.

- [ ] **Step 12.4: Commit**

```bash
git add data/sites/hooks.ts
git commit -m "refactor(data/sites): replace SiteWithLatest.userId with organizationId

Matches the API contract from the refactored /api/sites endpoints.
createdByUserId is exposed too (nullable, for audit trail UI later)."
```

---

## Task 13: Create post-migration smoke test

**Files:**

- Create: `scripts/verify-org-sites.ts`
- Modify: `package.json`

- [ ] **Step 13.1: Create the smoke test**

Create `scripts/verify-org-sites.ts`:

```ts
import { and, eq, like, notExists } from "drizzle-orm";
import { nanoid } from "nanoid";

import { auth } from "../lib/auth";
import { db } from "../lib/db";
import { member, organization, sites, user } from "../lib/db/schema";

const TEST_EMAIL = `smoke-sites-${nanoid(8)}@elevaseo.com`;
const TEST_NAME = "Smoke Sites";
const TEST_PASSWORD = "SmokeSites1234!";

async function main() {
  console.log(`→ Signing up ${TEST_EMAIL}`);
  const res = await auth.api.signUpEmail({
    body: { name: TEST_NAME, email: TEST_EMAIL, password: TEST_PASSWORD },
  });
  const userId = res.user.id;

  try {
    // Resolve the user's auto-created Personal Org id (from Phase 1).
    const mem = await db.select().from(member).where(eq(member.userId, userId)).limit(1);
    if (mem.length === 0) throw new Error("Personal org missing — Phase 1 hook may be broken");
    const orgId = mem[0]!.organizationId;
    console.log(`→ Org: ${orgId}`);

    // Directly insert a site as if /api/sites POST ran with this user's active org.
    const siteId = nanoid();
    await db.insert(sites).values({
      id: siteId,
      organizationId: orgId,
      createdByUserId: userId,
      hostname: "example.com",
      url: "https://example.com/",
      label: null,
      createdAt: new Date().toISOString(),
    });

    // Read it back filtered by org (the prod query pattern).
    const found = await db
      .select()
      .from(sites)
      .where(and(eq(sites.organizationId, orgId), eq(sites.id, siteId)));
    if (found.length !== 1) throw new Error(`Expected 1 site, got ${found.length}`);
    if (found[0]!.createdByUserId !== userId) {
      throw new Error(`createdByUserId mismatch: got ${found[0]!.createdByUserId}`);
    }
    console.log(`✓ Site round-trip OK: id=${siteId}`);

    // Try reading by the old user_id pattern — should NOT find anything
    // (the column doesn't exist anymore; ensure we don't have a stale field).
    // Just assert that the row has no `userId` property at runtime by checking
    // its keys.
    const keys = Object.keys(found[0]!);
    if (keys.includes("userId")) {
      throw new Error("Stale userId field present on site row");
    }
    if (!keys.includes("organizationId")) {
      throw new Error("organizationId field missing from site row");
    }
    console.log(`✓ Row shape OK: ${keys.join(",")}`);
  } finally {
    // Cleanup: delete the user (cascades member; site cascades via org).
    // Then sweep any orphan personal orgs we created.
    await db.delete(user).where(eq(user.id, userId));
    await db
      .delete(organization)
      .where(
        and(
          like(organization.name, "Personal — smoke-sites-%"),
          notExists(
            db
              .select({ one: member.id })
              .from(member)
              .where(eq(member.organizationId, organization.id)),
          ),
        ),
      );
    console.log(`→ Cleanup done`);
  }

  console.log(`✅ verify-org-sites OK`);
  process.exit(0);
}

main().catch((e) => {
  console.error(`❌ verify-org-sites FAILED:`, e instanceof Error ? e.message : e);
  process.exit(1);
});
```

- [ ] **Step 13.2: Add to `package.json`**

In the `"scripts"` section, add after `verify:org-signup`:

```json
"verify:org-sites": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/verify-org-sites.ts",
```

- [ ] **Step 13.3: Run it**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm verify:org-sites
```

Expected: ends with `✅ verify-org-sites OK`. If it fails, report BLOCKED.

- [ ] **Step 13.4: Verify zero leftover smoke users**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT COUNT(*) FROM \"user\" WHERE email LIKE 'smoke-sites-%';"
```

Expected: `0`. Don't print the password.

- [ ] **Step 13.5: Commit**

```bash
git add scripts/verify-org-sites.ts package.json
git commit -m "test: add smoke test verifying site round-trip via organizationId

scripts/verify-org-sites.ts signs up a fresh user, asserts the auto-created
Personal Org is resolvable, inserts a site with organizationId +
createdByUserId, reads it back filtered by org, and verifies the row shape
no longer has a userId field. Cleans up in finally{} so a failing assertion
doesn't leak smoke users."
```

---

## Task 14: Final Phase 2 validation

- [ ] **Step 14.1: Re-run all smoke tests**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm verify:org-signup
pnpm verify:org-sites
pnpm db:backfill-orgs    # should report 0 to process
```

All three must exit 0.

- [ ] **Step 14.2: Typecheck + lint + deadcode**

```bash
pnpm typecheck 2>&1 | tail -3
pnpm lint 2>&1 | tail -3
pnpm deadcode 2>&1 | tail -20
```

Typecheck and lint must pass. Deadcode shows pre-existing items only (no new unused exports from this phase). The new `requireOrgSession` is used in `dashboard/sites/[siteId]/page.tsx` so it should NOT appear as unused.

- [ ] **Step 14.3: Format the new files**

```bash
pnpm format:check scripts/verify-org-sites.ts 2>&1 | tail -3
pnpm format:check lib/auth-guards.ts lib/sitespeed-launch.ts 2>&1 | tail -3
```

If any of these new/modified files have format issues, run `pnpm oxfmt <file>` and amend with a `chore: format` commit at the end. Pre-existing format debt on other files stays untouched (already tracked in `docs/planning/pre-prod-checklist.md`).

- [ ] **Step 14.4: Verify final DB state**

```bash
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT (SELECT COUNT(*) FROM es_site WHERE organization_id IS NULL) AS sites_null_org, (SELECT COUNT(*) FROM es_site) AS sites_total, (SELECT COUNT(*) FROM es_site_test WHERE organization_id IS NULL) AS tests_null_org, (SELECT COUNT(*) FROM es_site_test) AS tests_total;"
```

Expected:

- `sites_null_org` = `0` (every site has an org).
- `sites_total` matches the pre-migration count.
- `tests_null_org` ≥ `0` (anonymous tests are allowed NULL).
- `tests_total` matches the pre-migration count.

- [ ] **Step 14.5: Manual E2E browser verification**

Tell the user to run `! pnpm dev:agent` and log in as each test account:

- `bastien@elevaseo.com` / `Eleva2025!` — should see his sites (admin platform).
- `owner-test@elevaseo.com` / `OwnerTest!2026` — should see no sites (fresh account, no sites yet).
- `admin-test@elevaseo.com` / `AdminTest!2026` — should see no sites; switch active org via DB if needed to test the team org case (Phase 3 will add the UI switcher).
- `member-test@elevaseo.com` / `MemberTest!2026` — same.

Note: the Org Switcher UI is Phase 3, so users automatically see their own Personal Org. To test the team-org membership case, manually update `session.active_organization_id` in DB for `admin-test` / `member-test` and reload. **Don't block on this** — Phase 3 will make it self-service.

- [ ] **Step 14.6: Update `docs/planning/pre-prod-checklist.md` if needed**

If anything noteworthy came out of the migration (e.g., orphan sites had to be hand-fixed, or a backfill SQL needed manual adjustment), note it. Otherwise skip.

- [ ] **Step 14.7: Log the Phase 2 commits**

```bash
git log --oneline dev..HEAD
```

Expected: ~13 commits, all `feat(...)`, `refactor(...)`, or `test(...)`, telling the story:

1. `feat(db): add organization_id + rename user_id on es_site / es_site_test`
2. `feat(auth): add requireOrgSession() guard for org-scoped routes`
3. `refactor(sitespeed): replace userId arg with organizationId + triggeredByUserId`
4. `refactor(api/sites): scope GET/POST + first test by activeOrganizationId`
5. `refactor(api/sites/[siteId]): scope detail + DELETE + test endpoints by orgId`
6. `refactor(api/integrations): anonymous audits keep organizationId = null`
7. `refactor(api/cron): scheduled audits pass organizationId + null triggeredByUserId`
8. `refactor(dashboard/sites): scope detail page + actions by activeOrganizationId`
9. `refactor(admin): pivot sites/users/tests views to organization-based joins`
10. `refactor(data/sites): replace SiteWithLatest.userId with organizationId`
11. `test: add smoke test verifying site round-trip via organizationId`
12. (optional) `chore: format Phase 2 new files`

---

## Critères d'acceptation Phase 2

- ✅ `es_site.organization_id` NOT NULL avec FK cascade, `es_site.created_by_user_id` NULLABLE avec FK set-null.
- ✅ `es_site_test.organization_id` NULLABLE (anon allowed), `es_site_test.triggered_by_user_id` NULLABLE.
- ✅ Backfill SQL exécuté : 0 row orpheline sur `es_site`.
- ✅ Toutes les queries domaine (`/api/sites/*`, `/api/integrations/audit/*`, `/api/cron/*`, dashboard, admin) filtrent par `organization_id`.
- ✅ `lib/sitespeed-launch.ts::launchSitespeedTest` accepte `organizationId` + `triggeredByUserId`.
- ✅ `lib/auth-guards.ts::requireOrgSession()` disponible pour les Server Components org-scoped.
- ✅ `scripts/verify-org-sites.ts` smoke test PASS.
- ✅ `pnpm typecheck && pnpm lint` PASS.
- ✅ Existing user data préservée (bastien@elevaseo.com voit toujours ses sites via sa Personal Org).
- ✅ Anonymous audits via `/api/integrations/audit/` continuent de fonctionner avec `organization_id = NULL`, `triggered_by_user_id = NULL`.

## Next phase

Une fois Phase 2 mergée dans `dev`, on rédige le plan Phase 3 : **Org switcher UI + onboarding page** (`/onboarding`, `<OrgSwitcher>` dans la sidebar, Server Action `switchActiveOrganization`, page `/accept-invitation/[id]`).
