echos-ocr/.cursor/skills/add-api-route/SKILL.md

157 lines
5.5 KiB
Markdown
Raw Normal View History

Bootstrap agent-context pipeline (L1 + L2 + L3) 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>
2026-05-23 15:55:18 -04:00
---
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`.