diff --git a/docs/superpowers/plans/2026-07-28-phase1-foundation.md b/docs/superpowers/plans/2026-07-28-phase1-foundation.md new file mode 100644 index 0000000..f39b727 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-phase1-foundation.md @@ -0,0 +1,3092 @@ +# Phase 1 (Foundation) 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:** Ship a deployed, authenticated, multi-tenant Next.js app — registration, email verification, login, password reset, tenant-scoped sessions, a base UI shell, and a live Railway staging deploy with a fail-loud `/health` endpoint — matching `todo.md`'s Phase 1 checklist and the approved spec at `docs/superpowers/specs/2026-07-28-phase1-foundation-design.md`. + +**Architecture:** Single Next.js 15 App Router app (TypeScript) deployed as one Railway service. Prisma/PostgreSQL holds `Business`/`User`/`Membership` (many-to-many via join table) plus the Auth.js adapter tables. Redis/BullMQ is wired and proven with a smoke-test job (no real jobs yet — Phase 3). A single `getSessionContext()` helper is the only path any route uses to resolve tenant scope, and centralized Zod schemas validate every boundary. + +**Tech Stack:** Next.js 15 (App Router) + TypeScript + Tailwind + shadcn/ui, Prisma + PostgreSQL, Redis + BullMQ, Auth.js v5 (`next-auth@beta` + `@auth/prisma-adapter`) with database sessions, Resend (auth emails only), Zod, Vitest + React Testing Library + Playwright, pino + Sentry, pnpm, Railway. + +## Global Constraints + +- Every tenant-scoped table carries `businessId`; every query is scoped from the authenticated session server-side, never a client-supplied ID. (AGENTS.md) +- Session cookies: httpOnly, secure, sameSite. CSRF protection on all state-changing routes. (AGENTS.md, todo.md) +- Validate at every boundary: Zod on every API route/form handler, server-side, before DB/queue/provider calls. (AGENTS.md) +- Fail loud: provider errors logged with context, never swallowed. (AGENTS.md) +- Migrations are additive-first; no destructive changes without a documented follow-up. (AGENTS.md) +- Package manager is pnpm; scripts are `dev`, `build`, `start`, `test`, `lint`, `typecheck`, `test:e2e` (already defined in `package.json` from Phase 0 — this plan replaces their placeholder bodies with real tool invocations). +- Node >= 20 (CI runner uses Node 22 per `.github/workflows/ci.yml`). +- No feature beyond Phase 1's scope: no Twilio/Stripe/Google Calendar wiring, no Customer/Appointment models, no reminder jobs. (todo.md Phase 1 scope; AGENTS.md "don't build out-of-scope features") +- Auth.js v5's Credentials-style custom login CANNOT use `session.strategy: "jwt"` alongside a registered `Credentials` provider without triggering `UnsupportedStrategy` if strategy is forced to `"jwt"` — and cannot use the adapter's implicit "database" strategy through NextAuth's own `signIn()` for Credentials at all (NextAuth hard-requires JWT for any registered Credentials provider). This plan resolves that by never registering a Credentials provider with NextAuth (`providers: []`), and instead the app's own `/app/(public)/login` Server Action creates the `Session` row directly via Prisma and sets the `authjs.session-token` cookie itself. `auth()`, `signOut()`, and the Prisma adapter continue to come from NextAuth. This was verified against current Auth.js docs (Context7 `/websites/authjs_dev`) during planning — flagged here per AGENTS.md "flag drift, don't guess" since it deviates from a naive reading of the spec's "Auth.js Credentials provider" phrasing while fully preserving the spec's actual requirement (revocable DB-backed sessions with `activeBusinessId`). + +--- + +## File Structure + +``` +prisma/ + schema.prisma # Business, User, Membership, Role enum, Auth.js adapter tables + activeBusinessId +lib/ + prisma.ts # Prisma client singleton + redis.ts # ioredis client singleton + auth.ts # NextAuth config: adapter, session.strategy="database", providers=[] + session.ts # createDatabaseSession, getSessionContext, SESSION_COOKIE_NAME + password.ts # hashPassword, verifyPassword (argon2) + csrf.ts # assertSameOrigin(req) — Origin/Sec-Fetch-Site check + logger.ts # pino instance + sentry.ts # Sentry init helper (server-only capture wrapper) + resend.ts # Resend client (lazy) + sendVerificationEmail/sendPasswordResetEmail + __capturedEmails (CAPTURE_EMAILS-gated dev capture) + queue.ts # BullMQ Queue instance + smoke-test job definition + validation/ + auth.schema.ts # registerSchema, loginSchema, requestPasswordResetSchema, resetPasswordSchema + parse.ts # parseBody(schema, formDataOrJson) -> {success, data} | {success:false, issues} +app/ + (public)/ + layout.tsx # public shell (header, no sidebar) + page.tsx # marketing placeholder homepage + login/ + page.tsx + actions.ts # loginAction (Server Action) — creates DB session directly + signup/ + page.tsx + actions.ts # registerAction (Server Action) + verify-email/ + page.tsx # handles ?token=... verification link + reset-password/ + page.tsx # request-reset form (no token) and reset form (?token=...) in one page + actions.ts # requestPasswordResetAction, resetPasswordAction + (app)/ + layout.tsx # authed shell: sidebar placeholder + verify-email banner slot + dashboard/ + page.tsx # empty dashboard placeholder at /dashboard (NOT / — see note) + api/ + health/ + route.ts # GET /health — DB + Redis check, fail-loud 503 + auth/ + [...nextauth]/ + route.ts # NextAuth GET/POST handlers (adapter plumbing, signOut) + logout/ + route.ts # POST — same-origin-checked, deletes Session row + clears cookie + __dev/ + last-email/ + route.ts # dev/test-only — returns last captured email link; 404 unless CAPTURE_EMAILS=1 + layout.tsx # root layout (Tailwind globals, fonts) + globals.css +components/ + ui/ # shadcn/ui generated components (button, input, card, form, alert) + verify-email-banner.tsx +tests/ + unit/ + password.test.ts + session.test.ts + validation.auth.test.ts + csrf.test.ts + component/ + login-form.test.tsx + signup-form.test.tsx + reset-password-form.test.tsx + e2e/ + auth-flows.spec.ts +railway.toml # healthcheckPath = /health +playwright.config.ts # E2E config (webServer runs build+start with CAPTURE_EMAILS=1, MOCK_EMAIL_SEND=1) +.env.example # already exists (Phase 0) — no new vars needed, all already listed +``` + +**Route-group note:** Next.js route groups `(public)`/`(app)` do not affect the URL path, so both `app/(public)/page.tsx` and `app/(app)/page.tsx` would resolve to `/` and conflict (build error). The dashboard therefore lives at `/dashboard` (`app/(app)/dashboard/page.tsx`); the marketing homepage owns `/` (`app/(public)/page.tsx`). Task 1 creates a temporary root `app/page.tsx` placeholder so the app builds before route groups exist; Task 13 deletes it when it creates `app/(public)/page.tsx` (having both at `/` is a conflict, so the delete and the create must land together). + +**Interfaces contract (so later tasks match earlier ones exactly):** +- `lib/prisma.ts` exports `prisma: PrismaClient` (default singleton pattern, `globalThis` cache). +- `lib/redis.ts` exports `redis: Redis` (ioredis instance) and `REDIS_URL` read from env. +- `lib/password.ts` exports `hashPassword(plain: string): Promise` and `verifyPassword(plain: string, hash: string): Promise`. +- `lib/session.ts` exports: + - `SESSION_COOKIE_NAME: string` (`"authjs.session-token"` dev / `"__Secure-authjs.session-token"` prod, resolved by a helper `getSessionCookieName(): string`) + - `createDatabaseSession(userId: string, activeBusinessId: string): Promise<{ sessionToken: string; expires: Date }>` + - `getSessionContext(): Promise<{ userId: string; businessId: string; role: Role } | null>` — reads the cookie, loads the `Session` row + joined `Membership`, returns `null` if missing/expired/membership revoked. Never throws for the "no session" case; throws only on unexpected DB failure (fail-loud via Sentry). + - `deleteDatabaseSession(sessionToken: string): Promise` +- `lib/csrf.ts` exports `assertSameOrigin(headers: Headers): void` — throws `CsrfError` (custom class) if `Origin`/`Sec-Fetch-Site` don't match `APP_URL`. +- `lib/validation/parse.ts` exports `parseBody(schema: ZodSchema, data: unknown): { success: true; data: T } | { success: false; issues: ZodIssue[] }`. +- `lib/validation/auth.schema.ts` exports `registerSchema`, `loginSchema`, `requestPasswordResetSchema`, `resetPasswordSchema` (all `z.object(...)`). +- `lib/resend.ts` exports `sendVerificationEmail(to: string, token: string): Promise`, `sendPasswordResetEmail(to: string, token: string): Promise`, and `__capturedEmails: { to: string; link: string }[]` (populated only when `CAPTURE_EMAILS=1`; the send is skipped entirely when `MOCK_EMAIL_SEND=1`). The Resend client is constructed lazily so the module loads even without `RESEND_API_KEY`. +- `lib/queue.ts` exports `smokeQueue: Queue` and `SMOKE_JOB_NAME: string`. +- `lib/logger.ts` exports default `logger` (pino instance). +- `lib/sentry.ts` exports `captureServerError(err: unknown, context?: Record): void`. + +--- + +## Task 1: Next.js scaffold + Tailwind + shadcn/ui + base layout + +Hand-write the scaffold. Phase 0 already created `package.json`, `.gitignore`, `.github/`, `docs/`; running `create-next-app .` into a non-empty directory is unreliable — it can refuse to run or clobber Phase 0's `packageManager`/`engines` fields unpredictably. We control versions explicitly instead. Tailwind v3 (not v4) is used deliberately: it's the most battle-tested pairing with shadcn/ui and matches the `tailwind.config.ts` + CSS-variable theme pattern used below. + +**Files:** +- Create: `next.config.ts`, `tsconfig.json`, `eslint.config.mjs`, `postcss.config.mjs`, `tailwind.config.ts`, `components.json`, `lib/utils.ts`, `app/layout.tsx`, `app/globals.css`, `app/page.tsx` (temporary homepage placeholder — replaced by `app/(public)/page.tsx` and deleted in Task 13) +- Modify: `package.json` (real scripts + dependencies — preserve `name`/`private`/`license`/`packageManager`/`engines` from Phase 0) +- Test: none (scaffolding task; verified by `pnpm build` succeeding) + +**Interfaces:** +- Consumes: nothing (first task) +- Produces: a running Next.js app shell that later tasks add routes/components into. `app/layout.tsx` is the root layout wrapping all routes; `app/page.tsx` is a throwaway so the app builds before route groups exist. + +- [ ] **Step 1: Install runtime + dev dependencies** + +Run: +```bash +pnpm add next@^15 react react-dom +pnpm add -D typescript @types/node @types/react @types/react-dom \ + eslint eslint-config-next @eslint/eslintrc \ + tailwindcss@3 postcss autoprefixer tailwindcss-animate \ + class-variance-authority clsx tailwind-merge lucide-react +``` + +- [ ] **Step 2: Replace package.json scripts (preserve Phase 0 metadata)** + +Edit `package.json` — keep `name`, `private`, `license`, `packageManager`, `engines` exactly as Phase 0 set them; set scripts: +```json +"scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" +} +``` +(`dependencies`/`devDependencies` are written by pnpm in Step 1 — do not hand-edit them.) + +- [ ] **Step 3: Write config files** + +Create `tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +Create `next.config.ts`: +```typescript +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; +``` +(Task 6 wraps this export with `withSentryConfig`.) + +Create `eslint.config.mjs` (Next 15 flat config): +```javascript +import { FlatCompat } from "@eslint/eslintrc"; + +const compat = new FlatCompat({ baseDirectory: import.meta.dirname }); + +export default [...compat.extends("next/core-web-vitals", "next/typescript")]; +``` + +Create `postcss.config.mjs`: +```javascript +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +export default config; +``` + +Create `tailwind.config.ts`: +```typescript +import type { Config } from "tailwindcss"; +import animate from "tailwindcss-animate"; + +const config: Config = { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" }, + secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" }, + muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" }, + accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" }, + destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" }, + card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [animate], +}; + +export default config; +``` + +Create `components.json` (shadcn config; lets `shadcn add` write into `components/ui/`): +```json +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} +``` + +Create `lib/utils.ts`: +```typescript +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +Create `app/globals.css` — Tailwind v3 directives plus the shadcn "neutral" base-color CSS variables. Use the canonical shadcn v3 globals.css: `@tailwind base; @tailwind components; @tailwind utilities;` then `:root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; ... }` and `.dark { ... }` (the full neutral variable set), then: +```css +@layer base { + * { @apply border-border; } + body { @apply bg-background text-foreground; } +} +``` +Copy the exact `:root`/`.dark` variable blocks from the shadcn "neutral" theme reference so `hsl(var(--*))` tokens resolve. + +Create `app/layout.tsx` (root layout — the only layout in this task): +```tsx +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "CustomerETA", + description: "Automated appointment reminders for service businesses.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +Create `app/page.tsx` (temporary homepage so the app builds before route groups exist; Task 13 deletes this and creates `app/(public)/page.tsx` in the same commit to avoid a `/` route conflict): +```tsx +export default function Home() { + return
CustomerETA
; +} +``` + +- [ ] **Step 4: Add shadcn/ui components** + +`components.json` already exists, so `shadcn add` writes into `components/ui/`. Run: +```bash +pnpm dlx shadcn@latest add button input card label +``` +(Only the components the auth forms use. shadcn `Form`/`Alert` are intentionally NOT added — the Phase 1 forms use plain `
` + ``/`