# Onboarding Wizard Implementation Plan

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

**Goal:** A `/onboarding` wizard (Langue → Forfait → Équipe) gated by `user.onboardingCompletedAt`, read FRESH from the DB to avoid Better Auth's 5-min session-cookie-cache loop. Reuses `LanguageSelector` + `inviteMember`. Paid plans/CB dormant (sub-project 3); agency whitelisting deferred (sub-project 4).

**Architecture:** Standalone authed route outside `(app)`. `(app)/layout.tsx` redirects users whose flag is unset to `/onboarding`; `/onboarding` redirects already-completed users into the app. Client stepper; each step persists immediately; `completeOnboarding()` sets the flag at the end.

**Tech Stack:** Next 16.2.7, React 19.2.7, Better Auth 1.6.14, Drizzle+Postgres, next-intl 4.13, bun test.

**Spec:** `docs/superpowers/specs/2026-06-12-onboarding-wizard-design.md`

**Conventions:** Conventional Commits; commit bodies end with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. Explicit `git add <paths>` only — NEVER `git add -A` (untracked `docs/project/bug-fix.md` must never be staged). Branch `feature/onboarding-wizard` is checked out. Gate before each code commit: `bun run test:all`. Never `db:push`.

---

### Task 1: Data — user.onboardingCompletedAt + additionalField + migration 0030

**Files:** Modify `src/lib/db/auth-schema.ts`, `src/lib/auth.ts`; create `drizzle/0030_*`.

- [ ] **Step 1: Add the column.** In `src/lib/db/auth-schema.ts` `user` table, after the `locale` line:
```ts
onboardingCompletedAt: timestamp("onboarding_completed_at"),
```
(`timestamp` is already imported.)

- [ ] **Step 2: Expose via Better Auth.** In `src/lib/auth.ts`, in the existing `user.additionalFields` object (added for `locale`), add:
```ts
onboardingCompletedAt: { type: "date", required: false, input: false },
```

- [ ] **Step 3: Generate.** Run `bun run db:generate`. Confirm `drizzle/0030_*.sql` contains `ALTER TABLE "user" ADD COLUMN "onboarding_completed_at" timestamp;`. Note the filename.

- [ ] **Step 4: Fix journal `when` (CRITICAL).** In `drizzle/meta/_journal.json`, set the new `idx: 30` entry's `when` to exactly `1781310000000` (current max is `1781300000000` for 0029). Verify it is now the max.

- [ ] **Step 5: Apply.** `bun run db:migrate` then `bun run db:migrate:test`. Both must succeed. (Transient "column does not exist" under the live dev server → re-run.)

- [ ] **Step 6: Gate + commit.**
Run `bun run test:all` → PASS.
```bash
git add src/lib/db/auth-schema.ts src/lib/auth.ts drizzle/0030_*.sql drizzle/meta/_journal.json drizzle/meta/0030_snapshot.json
git commit -m "feat(onboarding): add user.onboardingCompletedAt + additionalField

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 2: Gating helpers + tests (needsOnboarding, hasPendingOnboarding, completeOnboarding)

**Files:** Create `src/lib/onboarding/needs-onboarding.ts`, `needs-onboarding.test.ts`, `actions.ts`, `actions.test.ts`.

- [ ] **Step 1: Failing test for the pure helper.** `src/lib/onboarding/needs-onboarding.test.ts`:
```ts
import { describe, expect, it } from "bun:test";
import { needsOnboarding } from "./needs-onboarding";

describe("needsOnboarding", () => {
  it("is true when the flag is null/undefined", () => {
    expect(needsOnboarding(null)).toBe(true);
    expect(needsOnboarding(undefined)).toBe(true);
  });
  it("is false once a completion date exists", () => {
    expect(needsOnboarding(new Date())).toBe(false);
  });
});
```
Run `bun run test src/lib/onboarding/needs-onboarding.test.ts` → FAIL (module not found).

- [ ] **Step 2: Implement the helpers.** `src/lib/onboarding/needs-onboarding.ts`:
```ts
import "server-only";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { user } from "@/lib/db/auth-schema";

export function needsOnboarding(completedAt: Date | null | undefined): boolean {
  return completedAt == null;
}

// Fresh DB read — bypasses Better Auth's 5-min session cookieCache so a
// just-completed user is never bounced back into the wizard.
export async function hasPendingOnboarding(userId: string): Promise<boolean> {
  const [row] = await db
    .select({ completedAt: user.onboardingCompletedAt })
    .from(user)
    .where(eq(user.id, userId))
    .limit(1);
  return needsOnboarding(row?.completedAt);
}
```
NOTE: importing `server-only` makes this importable only from server components/actions — that's correct (it's used in the layout + page + action). The pure `needsOnboarding` is still unit-testable because bun runs it server-side. If `server-only` breaks the bun test import, split the pure function into `needs-onboarding-core.ts` (no `server-only`) and re-export; prefer NOT splitting unless the test fails.

Run `bun run test src/lib/onboarding/needs-onboarding.test.ts` → PASS.

- [ ] **Step 3: Failing test for completeOnboarding.** `src/lib/onboarding/actions.test.ts` — follow the repo's DB-test pattern (guard with the project's test-DB assertion; the suite already sets `DATABASE_URL=...seo_insight_test` for the `test` script). Create a user row, call `completeOnboarding` with a mocked/seeded session for that user, assert `onboarding_completed_at` is non-null. Look at an existing action test (e.g. under `src/lib/support` or `src/lib/keywords`) to match the session-injection pattern used in this repo BEFORE writing this test. If the repo's actions read session via `auth.api.getSession` with no injection seam, test `completeOnboarding` by asserting it throws when unauthenticated, and test the DB-write path via a thin exported `markOnboardingComplete(userId)` helper that `completeOnboarding` calls (inject `userId`, no session). Prefer the helper seam — it keeps the action testable without mocking Better Auth.

Concretely, structure `actions.ts` as:
```ts
"use server";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db/client";
import { user } from "@/lib/db/auth-schema";

export async function markOnboardingComplete(userId: string): Promise<void> {
  await db.update(user).set({ onboardingCompletedAt: new Date() }).where(eq(user.id, userId));
}

export async function completeOnboarding(): Promise<void> {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user?.id) throw new Error("Not authenticated");
  await markOnboardingComplete(session.user.id);
  revalidatePath("/", "layout");
}
```
WAIT: a `"use server"` file may only export async functions — that's satisfied (both are async). But `markOnboardingComplete` being a server action is fine. Test `markOnboardingComplete(userId)` directly against the test DB.

`src/lib/onboarding/actions.test.ts`:
```ts
import { afterAll, describe, expect, it } from "bun:test";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { user } from "@/lib/db/auth-schema";
import { markOnboardingComplete } from "./actions";

const TEST_ID = "onb-test-user-1";

describe("markOnboardingComplete", () => {
  afterAll(async () => {
    await db.delete(user).where(eq(user.id, TEST_ID));
  });
  it("stamps onboardingCompletedAt", async () => {
    await db.delete(user).where(eq(user.id, TEST_ID));
    await db.insert(user).values({
      id: TEST_ID,
      name: "Onb Test",
      email: "onb-test-1@example.com",
      emailVerified: true,
    });
    await markOnboardingComplete(TEST_ID);
    const [row] = await db
      .select({ c: user.onboardingCompletedAt })
      .from(user)
      .where(eq(user.id, TEST_ID))
      .limit(1);
    expect(row?.c).not.toBeNull();
  });
});
```
Confirm the `user` insert shape matches the schema's NOT NULL columns (id, name, email, emailVerified — `locale` has a default). Adjust required fields if the schema demands more.

Run `bun run test src/lib/onboarding/actions.test.ts` → it should PASS once `actions.ts` exists (write `actions.ts` first if needed to satisfy the import).

- [ ] **Step 4: Gate + commit.**
Run `bun run test:all` → PASS.
```bash
git add src/lib/onboarding/needs-onboarding.ts src/lib/onboarding/needs-onboarding.test.ts src/lib/onboarding/actions.ts src/lib/onboarding/actions.test.ts
git commit -m "feat(onboarding): gating helpers + completeOnboarding action (+ tests)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 3: Onboarding namespace catalogs + parity test update

**Files:** Modify `messages/{en,fr,es,de}.json`, `src/i18n/catalogs.test.ts`.

- [ ] **Step 1: Fill the `Onboarding` namespace** in all four catalogs with the wizard copy. Keys to include (en = source; fr = French; es/de translated, informal register):
```
welcomeTitle, welcomeSubtitle,
stepLanguage, stepPlan, stepTeam,
languageTitle, languageHelp,
planTitle, planHelp, planFree, planFreeDesc, planComingSoon, planPro, planAgency,
teamTitle, teamHelp, teamEmailPlaceholder, teamRoleStaff, teamRoleAdmin, teamInvite, teamInvited, teamSkip,
back, continue, finish
```
Use clear English source values; provide accurate fr/es/de. Keep all four catalogs structurally identical.

- [ ] **Step 2: Include Onboarding in the parity test.** In `src/i18n/catalogs.test.ts`, remove the `Onboarding` exclusion so the namespace is now parity-checked. Change:
```ts
const enKeys = new Set(keyPaths(en).filter((k) => !k.startsWith("Onboarding")));
```
to:
```ts
const enKeys = new Set(keyPaths(en));
```

- [ ] **Step 3: Run parity.** `bun run test src/i18n/catalogs.test.ts` → PASS (fix any missing keys).

- [ ] **Step 4: Gate + commit.**
Run `bun run test:all` → PASS.
```bash
git add messages/en.json messages/fr.json messages/es.json messages/de.json src/i18n/catalogs.test.ts
git commit -m "feat(onboarding): Onboarding namespace catalogs + enforce parity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 4: Route shell + step components + wizard

**Files:** Create `src/app/onboarding/layout.tsx`, `src/app/onboarding/page.tsx`, `src/components/onboarding/onboarding-wizard.tsx`, `src/components/onboarding/steps/{language,plan,team}-step.tsx`.

- [ ] **Step 1: Full-screen layout.** `src/app/onboarding/layout.tsx` — a minimal server component that centers its children full-screen (e.g. `<div className="min-h-svh flex items-center justify-center p-6">{children}</div>`). No sidebar. (Root layout already provides theme + NextIntlClientProvider.)

- [ ] **Step 2: Page guard.** `src/app/onboarding/page.tsx` (server):
```tsx
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { hasPendingOnboarding } from "@/lib/onboarding/needs-onboarding";
import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard";

export default async function OnboardingPage() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user?.id) redirect("/login");
  if (!(await hasPendingOnboarding(session.user.id))) redirect("/keywords/reservoir");
  return <OnboardingWizard userName={session.user.name} />;
}
```

- [ ] **Step 3: Step components.** Create the three steps in `src/components/onboarding/steps/`. All client components using `useTranslations("Onboarding")`.
  - `language-step.tsx`: heading (`languageTitle`) + help (`languageHelp`) + `<LanguageSelector />` + a Continue button (calls `onNext`).
  - `plan-step.tsx`: `planTitle`/`planHelp`, then plan cards. Free card (`planFree`/`planFreeDesc`) is selectable and highlighted; Pro (`planPro`) and Agency (`planAgency`) cards are `disabled` with a `<Badge>` showing `planComingSoon`. A Continue button calls `onNext` (free is the implicit selection). Include `onBack`.
  - `team-step.tsx`: `teamTitle`/`teamHelp`. An email `Input` + role `Select` (`teamRoleStaff`/`teamRoleAdmin`, default staff) + Invite button calling `authClient.organization.inviteMember({ email, role })` (import `authClient` from `@/lib/auth-client`). On success, push the email into an "invited" list (show with `teamInvited`); on error, `toast.error`. Buttons: `teamSkip` and `finish` both call `onFinish`. Include `onBack`.

  Each step's props: `{ onNext?, onBack?, onFinish? }` as relevant.

- [ ] **Step 4: Wizard.** `src/components/onboarding/onboarding-wizard.tsx` (client):
```tsx
"use client";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { completeOnboarding } from "@/lib/onboarding/actions";
import { LanguageStep } from "./steps/language-step";
import { PlanStep } from "./steps/plan-step";
import { TeamStep } from "./steps/team-step";

type Step = "language" | "plan" | "team";

export function OnboardingWizard({ userName }: { userName?: string }) {
  const t = useTranslations("Onboarding");
  const [step, setStep] = useState<Step>("language");
  const [pending, startTransition] = useTransition();

  function finish() {
    startTransition(async () => {
      await completeOnboarding();
      window.location.href = "/keywords/reservoir";
    });
  }

  return (
    <div className="w-full max-w-xl">
      {/* progress indicator: language/plan/team, label via t("stepLanguage"|...) */}
      {step === "language" && <LanguageStep onNext={() => setStep("plan")} />}
      {step === "plan" && (
        <PlanStep onBack={() => setStep("language")} onNext={() => setStep("team")} />
      )}
      {step === "team" && (
        <TeamStep onBack={() => setStep("plan")} onFinish={finish} finishing={pending} />
      )}
    </div>
  );
}
```
Build a simple 3-dot/3-label progress header using the `stepLanguage/stepPlan/stepTeam` keys. Match the project's shadcn styling (Card, Button). Keep it clean and on-brand.

- [ ] **Step 5: Gate + lint.**
Run `bun run test:all` → PASS. `bun run lint` on touched files → clean.

- [ ] **Step 6: Commit.**
```bash
git add src/app/onboarding/layout.tsx src/app/onboarding/page.tsx src/components/onboarding/onboarding-wizard.tsx src/components/onboarding/steps/language-step.tsx src/components/onboarding/steps/plan-step.tsx src/components/onboarding/steps/team-step.tsx
git commit -m "feat(onboarding): /onboarding route + langue/forfait/équipe stepper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

### Task 5: Wire the gate + signup redirect

**Files:** Modify `src/app/(app)/layout.tsx`, `src/components/signup-form.tsx`.

- [ ] **Step 1: Gate in the app layout.** In `src/app/(app)/layout.tsx`, after `session` is resolved (it already calls `auth.api.getSession`), add before building the shell:
```ts
import { redirect } from "next/navigation";
import { hasPendingOnboarding } from "@/lib/onboarding/needs-onboarding";
// ...
if (session?.user?.id && (await hasPendingOnboarding(session.user.id))) {
  redirect("/onboarding");
}
```
Keep the existing `Promise.all` fetch; the gate runs after it (or restructure so the session is available — do not break the existing sites/site-context fetching). Simplest: keep the existing code, add the redirect check immediately after `session` is in scope.

- [ ] **Step 2: Signup redirect target.** In `src/components/signup-form.tsx`, change `const AFTER_SIGNUP = "/keywords/reservoir";` to `const AFTER_SIGNUP = "/onboarding";`.

- [ ] **Step 3: Gate + commit.**
Run `bun run test:all` → PASS.
```bash
git add src/app/\(app\)/layout.tsx src/components/signup-form.tsx
git commit -m "feat(onboarding): gate the app on onboarding + route signup to wizard

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Self-review notes
- **Spec coverage:** data (T1), gating helpers + action + tests (T2), catalogs + parity (T3), route/steps/wizard (T4), gate wiring + signup (T5). All spec sections covered.
- **cookieCache loop:** avoided via `hasPendingOnboarding` fresh DB read in both the gate (T5) and the page guard (T4) — never the cached session value.
- **`when` bump:** explicit in T1 step 4 (`1781310000000`).
- **Testability:** `completeOnboarding` split into a `markOnboardingComplete(userId)` seam (T2) so the DB write is testable without mocking Better Auth.
- **Reuse:** `LanguageSelector` (T4), `authClient.organization.inviteMember` (T4) — no new invite infra.
- **No `git add -A`:** every commit uses explicit paths.
- **Live verification (controller):** after T5, smoke via d3k — a fresh/incomplete user is redirected to `/onboarding`, can walk langue→forfait→équipe, and finishing lands in the app WITHOUT a redirect loop.
