From 541154b521baaf66e7ffe61130c3ef78ad29d2db Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 01:23:25 +0200 Subject: [PATCH] feat(PBI-74): cache-headers + LIST endpoints (Story 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/api/pbis/[id]/stories/route.ts | 52 +++++++++++++ app/api/products/[id]/backlog/route.ts | 100 +++++++++++++++++++++++++ app/api/stories/[id]/tasks/route.ts | 49 ++++++++++++ app/api/tasks/[id]/route.ts | 50 +++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 app/api/pbis/[id]/stories/route.ts create mode 100644 app/api/products/[id]/backlog/route.ts create mode 100644 app/api/stories/[id]/tasks/route.ts diff --git a/app/api/pbis/[id]/stories/route.ts b/app/api/pbis/[id]/stories/route.ts new file mode 100644 index 0000000..8cb760b --- /dev/null +++ b/app/api/pbis/[id]/stories/route.ts @@ -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) })), + ) +} diff --git a/app/api/products/[id]/backlog/route.ts b/app/api/products/[id]/backlog/route.ts new file mode 100644 index 0000000..14ef956 --- /dev/null +++ b/app/api/products/[id]/backlog/route.ts @@ -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 = {} + 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 = {} + 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, + }) +} diff --git a/app/api/stories/[id]/tasks/route.ts b/app/api/stories/[id]/tasks/route.ts new file mode 100644 index 0000000..9e437a4 --- /dev/null +++ b/app/api/stories/[id]/tasks/route.ts @@ -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) })), + ) +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 4bb2611..52cce01 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -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.