99 lines
3.8 KiB
Text
99 lines
3.8 KiB
Text
|
|
---
|
||
|
|
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<string, unknown> = {};
|
||
|
|
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`).
|