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:
parent
fad0374b1d
commit
541154b521
4 changed files with 251 additions and 0 deletions
52
app/api/pbis/[id]/stories/route.ts
Normal file
52
app/api/pbis/[id]/stories/route.ts
Normal 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) })),
|
||||
)
|
||||
}
|
||||
100
app/api/products/[id]/backlog/route.ts
Normal file
100
app/api/products/[id]/backlog/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
49
app/api/stories/[id]/tasks/route.ts
Normal file
49
app/api/stories/[id]/tasks/route.ts
Normal 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) })),
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue