---
title: "Route Handler (REST API)"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-08
when_to_read: "When writing a new Next.js route handler (GET/POST/PATCH/DELETE)."
---

# Patroon: Route Handler (REST API)

Alle endpoints vereisen: `Authorization: Bearer <token>`

## lib/api-auth.ts

```ts
import { createHash } from 'crypto'
import { prisma } from '@/lib/prisma'

export async function authenticateApiRequest(request: Request) {
  const authHeader = request.headers.get('Authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    return { error: 'Unauthorized', status: 401 }
  }

  const token = authHeader.slice(7)
  const tokenHash = createHash('sha256').update(token).digest('hex')

  const apiToken = await prisma.apiToken.findUnique({
    where: { token_hash: tokenHash },
    include: { user: true },
  })

  if (!apiToken || apiToken.revoked_at) {
    return { error: 'Unauthorized', status: 401 }
  }

  return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
}
```

## Route Handler

```ts
// app/api/products/[id]/next-story/route.ts
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const auth = await authenticateApiRequest(request)
  if ('error' in auth) {
    return Response.json({ error: auth.error }, { status: auth.status })
  }

  const { id } = await params

  const sprint = await prisma.sprint.findFirst({
    where: { product_id: id, status: 'OPEN', product: productAccessFilter(auth.userId) },
  })
  if (!sprint) {
    return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })
  }

  const story = await prisma.story.findFirst({
    where: { sprint_id: sprint.id, status: 'IN_SPRINT' },
    orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
    include: { tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] } },
  })

  if (!story) {
    return Response.json({ error: 'Geen open stories in de Sprint' }, { status: 404 })
  }

  return Response.json(story)
}
```

## POST /api/stories/:id/log — body schema

```json
{ "type": "IMPLEMENTATION_PLAN", "content": "string" }
{ "type": "TEST_RESULT", "content": "string", "status": "PASSED" | "FAILED" }
{ "type": "COMMIT", "content": "string", "commit_hash": "string", "commit_message": "string" }
```

## Alle endpoints

| Methode | Endpoint | Doel |
|---|---|---|
| GET | `/api/health` | Liveness; `?db=1` voor DB-ping (geen auth) |
| GET | `/api/products` | Actieve producten ophalen |
| GET | `/api/products/:id/next-story` | Hoogst geprioriteerde open story |
| GET | `/api/products/:id/claude-context` | Bundled MCP-context |
| GET | `/api/sprints/:id/tasks?limit=10` | Eerste N taken van de Sprint |
| PATCH | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen |
| POST | `/api/stories/:id/log` | Plan / testresultaat / commit vastleggen |
| PATCH | `/api/tasks/:id` | Taakstatus / `implementation_plan` bijwerken |
| GET / POST | `/api/ideas`, `GET / PATCH /api/ideas/:id` | Idea CRUD (vervangt voormalig `/api/todos`) |
| GET | `/api/jobs/:id/sub-tasks` | `sprint_task_executions` van een SPRINT_IMPLEMENTATION-job |

## Security-invarianten

- Elk endpoint start met `authenticateApiRequest`.
- Schrijf-endpoints geven `403` voor demo-tokens.
- Product-scoped reads en writes gebruiken `productAccessFilter(auth.userId)`, zodat eigenaar en gekoppeld teamlid hetzelfde toegangsmodel volgen.
- Endpoints die geordende ID-lijsten ontvangen valideren dat elke ID bij de parent-resource hoort voordat er wordt geupdated.
- JSON bodies worden met Zod gevalideerd; TypeScript types zijn geen runtime-beveiliging.
