--- description: Conventions for Next.js App Router API route handlers in this repo globs: src/app/api/**/route.ts --- # API Route Conventions Every `src/app/api/**/route.ts` follows the same shape: auth → parse body → query Prisma scoped to `orgId` → return JSON, all wrapped in `try / handleApiError`. ## Authentication & Authorization - Import from `@/lib/api-auth`: ```ts import { NextRequest, NextResponse } from "next/server"; import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth"; import { prisma } from "@/lib/db"; export async function GET(request: NextRequest) { try { const session = await requireApiAuthWithOrg(); // session.user.id, session.user.orgId, session.user.role // ... } catch (error) { return handleApiError(error); } } ``` - `requireApiAuthWithOrg()` returns an `OrgSession` (extends NextAuth `Session` with `user.id` and `user.orgId`) — both are always defined after this call. - Pass an optional `Action` to enforce a permission in one line: `await requireApiAuthWithOrg("cards.delete")`. Throws `PermissionError`; `handleApiError` returns 403. - For routes that don't need org scoping (rare — typically auth callbacks), use `requireApiAuth()` instead. ## Multi-tenancy is mandatory Every Prisma query against an org-scoped model (`ResponseCard`, `FormTemplate`, `Person`, `Integration`, …) MUST scope by `organizationId`: ```ts const card = await prisma.responseCard.findUnique({ where: { id } }); if (!card || card.organizationId !== session.user.orgId) { return NextResponse.json({ error: "Card not found" }, { status: 404 }); } ``` For lists: include `organizationId: session.user.orgId` in the `where` filter directly. Returning a 404 (not 403) on cross-org access is the convention so we don't leak existence. ## Request validation - Body parsing is hand-rolled today. Use the defensive pattern from `src/app/api/cards/[id]/route.ts`: ```ts const body = await request.json().catch(() => ({})); const data: Record = {}; const stringFields = ["name", "email", /* ... */]; for (const field of stringFields) { if (body[field] != null) data[field] = String(body[field]); } ``` - Zod is in `package.json` but not widely used. If you reach for it in a new route, that's fine — just stay consistent within the route. ## Error handling - Wrap every handler in `try { ... } catch (error) { return handleApiError(error); }`. Never throw to the framework. - `handleApiError` returns 401 for `ApiAuthError`, 403 for `PermissionError`, 500 (with `console.error`) for anything else. - For domain errors that aren't auth/permission, return `NextResponse.json({ error: "..." }, { status: 4xx })` directly — don't invent new error classes for one-off cases. ## Database access - Always `import { prisma } from "@/lib/db";` — the lazy `Proxy` singleton. Never `new PrismaClient()`. - Use `select` or `include` only when you need it; the default fetch is fine for small models. - For writes, prefer `update`/`create` over `upsert` unless you actually need both paths. ## Response shape - Success collections: `NextResponse.json({ items, total, page, limit })` (see `src/app/api/cards/route.ts` GET). - Success single: `NextResponse.json(record)` (no envelope). - Created: `NextResponse.json(record, { status: 201 })`. - Errors: `{ error: string, action?: string }` — `handleApiError` already does this. ## Dynamic routes Next.js 16 dynamic route params are async. Use: ```ts export async function GET( _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; // ... } ``` ## After adding / changing a route - Update the route table in `README.md` if it's a public-shape change. - If the route writes to `ResponseCard.firstName` or `lastName`, recompute `name` (see the canonical recompute block in `src/app/api/cards/[id]/route.ts`).