feat(PBI-74): cache-headers + LIST endpoints (Story 7)

- T-868: cache: 'no-store' was al ingebouwd in fetchJson helper (Story 1).
  Bevestigd door bestaande ensureProductLoaded test die de fetch-init
  controleert.
- T-869: force-dynamic toegevoegd op alle vier nieuwe LIST-endpoints.
- T-870: vier nieuwe routes voor ensure*Loaded:
  - GET /api/products/:id/backlog → ProductBacklogSnapshot
  - GET /api/pbis/:id/stories → BacklogStory[]
  - GET /api/stories/:id/tasks → BacklogTask[]
  - GET /api/tasks/:id (nieuwe handler naast bestaande PATCH) → TaskDetail
    met _detail: true marker
  Auth via authenticateApiRequest (Bearer of iron-session); access-control
  via productAccessFilter (gebruiker is owner of member van het product).
  Statussen worden via taskStatusToApi/storyStatusToApi/pbiStatusToApi
  vertaald naar lowercase API-vorm.
- T-871: SSE-route /api/realtime/backlog stuurt al ready-event direct na
  LISTEN (regel 106) — geen wijziging nodig.

Verify: lint+typecheck clean, 651/651 tests groen.

Refs: PBI-74, ST-1324, T-868..T-871

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 01:23:25 +02:00
parent fad0374b1d
commit 541154b521
4 changed files with 251 additions and 0 deletions

View file

@ -0,0 +1,52 @@
// PBI-74 / T-870: GET /api/pbis/:id/stories
//
// Levert stories binnen een PBI voor ensurePbiLoaded. Access-control via
// product-eigenaarschap van het bovenliggende PBI.
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { storyStatusToApi } from '@/lib/task-status'
export const dynamic = 'force-dynamic'
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 pbi = await prisma.pbi.findFirst({
where: { id, product: productAccessFilter(auth.userId) },
select: { id: true },
})
if (!pbi) {
return Response.json({ error: 'PBI niet gevonden' }, { status: 404 })
}
const stories = await prisma.story.findMany({
where: { pbi_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
select: {
id: true,
code: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
sort_order: true,
status: true,
pbi_id: true,
sprint_id: true,
created_at: true,
},
})
return Response.json(
stories.map((s) => ({ ...s, status: storyStatusToApi(s.status) })),
)
}

View file

@ -0,0 +1,100 @@
// PBI-74 / T-870: GET /api/products/:id/backlog
//
// Levert een volledige ProductBacklogSnapshot voor de workspace-store
// (ensureProductLoaded). Auth + access-control consistent met andere
// product-routes (authenticateApiRequest + productAccessFilter).
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { pbiStatusToApi, storyStatusToApi, taskStatusToApi } from '@/lib/task-status'
export const dynamic = 'force-dynamic'
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 product = await prisma.product.findFirst({
where: { id, ...productAccessFilter(auth.userId) },
select: { id: true, name: true },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const [pbis, stories, tasks] = await Promise.all([
prisma.pbi.findMany({
where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
select: {
id: true,
code: true,
title: true,
priority: true,
sort_order: true,
description: true,
created_at: true,
status: true,
},
}),
prisma.story.findMany({
where: { product_id: id },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
select: {
id: true,
code: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
sort_order: true,
status: true,
pbi_id: true,
sprint_id: true,
created_at: true,
},
}),
prisma.task.findMany({
where: { story: { product_id: id } },
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
select: {
id: true,
title: true,
description: true,
priority: true,
sort_order: true,
status: true,
story_id: true,
created_at: true,
},
}),
])
const storiesByPbi: Record<string, unknown[]> = {}
for (const story of stories) {
const apiStory = { ...story, status: storyStatusToApi(story.status) }
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
storiesByPbi[story.pbi_id].push(apiStory)
}
const tasksByStory: Record<string, unknown[]> = {}
for (const task of tasks) {
const apiTask = { ...task, status: taskStatusToApi(task.status) }
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
tasksByStory[task.story_id].push(apiTask)
}
return Response.json({
product,
pbis: pbis.map((p) => ({ ...p, status: pbiStatusToApi(p.status) })),
storiesByPbi,
tasksByStory,
})
}

View file

@ -0,0 +1,49 @@
// PBI-74 / T-870: GET /api/stories/:id/tasks
//
// Levert tasks binnen een story voor ensureStoryLoaded. Access-control via
// product-eigenaarschap van de bovenliggende story.
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { taskStatusToApi } from '@/lib/task-status'
export const dynamic = 'force-dynamic'
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 story = await prisma.story.findFirst({
where: { id, product: productAccessFilter(auth.userId) },
select: { id: true },
})
if (!story) {
return Response.json({ error: 'Story niet gevonden' }, { status: 404 })
}
const tasks = await prisma.task.findMany({
where: { story_id: id },
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
select: {
id: true,
title: true,
description: true,
priority: true,
sort_order: true,
status: true,
story_id: true,
created_at: true,
},
})
return Response.json(
tasks.map((t) => ({ ...t, status: taskStatusToApi(t.status) })),
)
}

View file

@ -3,6 +3,56 @@ import { prisma } from '@/lib/prisma'
import { z } from 'zod'
import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status'
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
import { productAccessFilter } from '@/lib/product-access'
// PBI-74 / T-869: force-dynamic zodat Next geen response-cache hangt aan
// deze route — workspace-store leest hier verse data via ensureTaskLoaded.
export const dynamic = 'force-dynamic'
// PBI-74 / T-870: GET-handler voor ensureTaskLoaded. Levert TaskDetail-shape
// (extends BacklogTask met implementation_plan etc.). Access-control via
// product van de parent-story.
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 task = await prisma.task.findFirst({
where: {
id,
story: { product: productAccessFilter(auth.userId) },
},
select: {
id: true,
title: true,
description: true,
priority: true,
sort_order: true,
status: true,
story_id: true,
created_at: true,
implementation_plan: true,
requires_opus: true,
verify_only: true,
verify_required: true,
},
})
if (!task) {
return Response.json({ error: 'Task niet gevonden' }, { status: 404 })
}
return Response.json({
...task,
status: taskStatusToApi(task.status),
_detail: true,
})
}
// `review` is a valid TaskStatus in the DB and the kanban-board UI, but the
// sprint task list (components/sprint/task-list.tsx) does not yet render it.