echos-ocr/.cursor/skills/add-api-route/SKILL.md
Randall Stillwell 9c1aaaa61f 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 14:55:18 -05:00

5.5 KiB

name description
add-api-route 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

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

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:

// Inline in the auth call — throws PermissionError → handleApiError returns 403
const session = await requireApiAuthWithOrg("cards.delete");
// 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:

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:

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.