Installs a 3-layer Cursor-aligned agent pipeline so future agent
sessions can orient quickly and stay inside guard rails:
L1 — context for any agent reading the repo:
- AGENTS.md (top-level orientation, conventions, no-go zones)
- .cursor/rules/ (no-go-zones, api-routes, prisma, prisma-schema-map)
- .cursor/skills/ (add-api-route, add-prisma-model)
- docs/SCHEMA_MAP.md generated from prisma/schema.prisma
- scripts/generate-schema-map.ts (regenerate the map; wired up as
`npm run schema:map`)
L2 — subagent roles for the 9-stage idea-to-feature pipeline:
- .cursor/agents/role-*.md (conductor, architect, ia-architect,
design-system-auditor, implementer, reviewer, ux-reviewer,
a11y-auditor, doc-writer) with explicit multitask annotations.
L3 — pipeline scaffolding:
- .github/CODEOWNERS, PR template, and CI workflows (ci.yml,
preview-smoke.yml, visual-diff.yml, pr-health-rollup.yml).
Test job is intentionally disabled until Playwright is wired up.
- .convoys/ folder for per-feature run notes + scripts/log-convoy-event.sh.
- scripts/wt.sh worktree helper.
- src/lib/flags/index.ts simple env-driven feature flag wrapper.
- tests/smoke/app.smoke.spec.ts (Playwright smoke; excluded from
tsc until @playwright/test is installed — see tsconfig change).
Also writes .agent-context-manifest.yml so the sync-agent-context
skill can detect drift and offer selective updates from upstream.
Follow-ups (not in this commit):
- Install @playwright/test and re-enable the test job in ci.yml.
- Review .cursor/agents/role-*.md and trim any roles that don't
apply to this codebase.
Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
5.5 KiB
Markdown
156 lines
5.5 KiB
Markdown
---
|
|
name: add-api-route
|
|
description: Add a new Next.js App Router API route in src/app/api/**/route.ts following the repo's auth + multi-tenancy + error-handling conventions. Use when the user asks to add an endpoint, route, handler, GET/POST/PUT/DELETE, /api/X, or a server action backed by Prisma.
|
|
---
|
|
|
|
# Add a new API route
|
|
|
|
Every API route in this repo is a `src/app/api/<segments>/route.ts` file that exports `GET` / `POST` / `PUT` / `DELETE`. They all follow the same shape: auth check → org-scoped Prisma access → JSON response, wrapped in `try / handleApiError`.
|
|
|
|
## Recipe
|
|
|
|
### 1. Pick the path
|
|
|
|
App Router maps directories to URL segments. Dynamic segments use `[param]`:
|
|
|
|
| Want | File |
|
|
| --- | --- |
|
|
| `GET /api/widgets` | `src/app/api/widgets/route.ts` |
|
|
| `GET /api/widgets/[id]` | `src/app/api/widgets/[id]/route.ts` |
|
|
| `POST /api/widgets/[id]/archive` | `src/app/api/widgets/[id]/archive/route.ts` |
|
|
|
|
### 2. Start from this template
|
|
|
|
```ts
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/db";
|
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const session = await requireApiAuthWithOrg();
|
|
const { searchParams } = new URL(request.url);
|
|
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
|
|
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10)));
|
|
|
|
const [items, total] = await Promise.all([
|
|
prisma.widget.findMany({
|
|
where: { organizationId: session.user.orgId },
|
|
orderBy: { createdAt: "desc" },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
}),
|
|
prisma.widget.count({ where: { organizationId: session.user.orgId } }),
|
|
]);
|
|
|
|
return NextResponse.json({ items, total, page, limit });
|
|
} catch (error) {
|
|
return handleApiError(error);
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const session = await requireApiAuthWithOrg("widgets.create");
|
|
const body = await request.json().catch(() => ({}));
|
|
if (!body.name) {
|
|
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
|
}
|
|
const widget = await prisma.widget.create({
|
|
data: {
|
|
organizationId: session.user.orgId,
|
|
name: String(body.name),
|
|
},
|
|
});
|
|
return NextResponse.json(widget, { status: 201 });
|
|
} catch (error) {
|
|
return handleApiError(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Dynamic route params are async in Next.js 16
|
|
|
|
```ts
|
|
export async function GET(
|
|
_request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const session = await requireApiAuthWithOrg();
|
|
const { id } = await params;
|
|
const widget = await prisma.widget.findUnique({ where: { id } });
|
|
if (!widget || widget.organizationId !== session.user.orgId) {
|
|
return NextResponse.json({ error: "Widget not found" }, { status: 404 });
|
|
}
|
|
return NextResponse.json(widget);
|
|
} catch (error) {
|
|
return handleApiError(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
Cross-org returns 404, not 403 (don't reveal existence).
|
|
|
|
### 4. Permission gating
|
|
|
|
Two ways to enforce a permission:
|
|
|
|
```ts
|
|
// Inline in the auth call — throws PermissionError → handleApiError returns 403
|
|
const session = await requireApiAuthWithOrg("cards.delete");
|
|
```
|
|
|
|
```ts
|
|
// Or check explicitly when the policy is fancier
|
|
import { can } from "@/lib/permissions";
|
|
const session = await requireApiAuthWithOrg();
|
|
if (!can(session.user.role, "cards.edit") && card.assignedToId !== session.user.id) {
|
|
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
|
|
}
|
|
```
|
|
|
|
`Action` is a union type in `src/lib/permissions.ts` — TypeScript will autocomplete the valid actions. Add a new action there if you need one.
|
|
|
|
### 5. Body handling
|
|
|
|
Hand-rolled is the prevailing style. Use the defensive pattern:
|
|
|
|
```ts
|
|
const body = await request.json().catch(() => ({}));
|
|
const data: Record<string, unknown> = {};
|
|
for (const field of ["name", "description"]) {
|
|
if (body[field] != null) data[field] = String(body[field]);
|
|
}
|
|
if (body.published != null) data.published = Boolean(body.published);
|
|
```
|
|
|
|
For complex shapes, Zod is in `package.json` — using it in a new route is fine, just stay consistent within the file.
|
|
|
|
### 6. Updating denormalized `ResponseCard.name`
|
|
|
|
If your route mutates `ResponseCard.firstName` or `lastName`, you MUST recompute `name`. The canonical block lives in `src/app/api/cards/[id]/route.ts` — copy it:
|
|
|
|
```ts
|
|
if (("firstName" in data || "lastName" in data) && !("name" in data)) {
|
|
const nextFirst = "firstName" in data ? (data.firstName as string | null) : card.firstName;
|
|
const nextLast = "lastName" in data ? (data.lastName as string | null) : card.lastName;
|
|
const combined = [nextFirst, nextLast].filter(Boolean).join(" ").trim();
|
|
data.name = combined || null;
|
|
}
|
|
```
|
|
|
|
### 7. After writing the route
|
|
|
|
- Verify locally with `curl` or the Network tab.
|
|
- If it's a user-visible endpoint, add a row to the API table in `README.md`.
|
|
- Run `npm run lint` and `npx tsc --noEmit` before opening the PR.
|
|
|
|
## Anti-patterns
|
|
|
|
- Skipping `organizationId` in `where:` filters → cross-org data leak.
|
|
- `throw` instead of `return handleApiError(error)` → uncaught error in the framework.
|
|
- Returning `403` for cross-org IDs instead of `404` → leaks existence.
|
|
- Calling `prisma` from top-level module code → the lazy proxy needs `DATABASE_URL` at access time.
|
|
- Adding `"use client"` to a route file → server-only.
|
|
- Hand-writing `NextResponse.json({ error }, { status: 500 })` in a catch block → use `handleApiError`.
|