feat(PBI-74): read-routes voor sprint-workspace + cache-headers (Story 9 / T-882)
- GET /api/products/[id]/sprints — lijst sprints per product
(ensureProductSprintsLoaded). force-dynamic, productAccessFilter,
start_date/end_date naar ISO-date string.
- GET /api/sprints/[id]/workspace — sprint snapshot met sprint-meta,
stories (incl. taskCount/doneCount/assignee), tasks gegroepeerd per
story (ensureSprintLoaded). force-dynamic, productAccessFilter via
product, status-vertaling via taskStatusToApi/storyStatusToApi.
Race-safe loaders (activeRequestId-guard), restore-flow (cascade-restore
via writeProductHint/writeSprintHint/writeStoryHint/writeTaskHint),
resync-laag (useSprintWorkspaceResync visibility + online), unknown-event
filter (isUnknownEntityEvent → resyncActiveScopes('unknown-event')) zijn
allemaal in T-879/T-880 al ingebouwd; T-882 sluit het loop met de
ontbrekende API-endpoints + cache-headers (cache: 'no-store' op fetches,
force-dynamic op routes).
This commit is contained in:
parent
c16d1ecbac
commit
fa8b21ebe4
2 changed files with 169 additions and 0 deletions
59
app/api/products/[id]/sprints/route.ts
Normal file
59
app/api/products/[id]/sprints/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// PBI-74 / Story 9 / T-882: GET /api/products/:id/sprints
|
||||
//
|
||||
// Levert een lijst sprints voor een product (sprint-workspace
|
||||
// ensureProductSprintsLoaded). Auth + access-control consistent met andere
|
||||
// product-routes.
|
||||
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
|
||||
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 },
|
||||
})
|
||||
if (!product) {
|
||||
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const sprints = await prisma.sprint.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [
|
||||
{ status: 'asc' }, // OPEN < CLOSED alfabetisch — workspace-store her-sorteert
|
||||
{ start_date: 'desc' },
|
||||
{ created_at: 'desc' },
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
product_id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
created_at: true,
|
||||
completed_at: true,
|
||||
},
|
||||
})
|
||||
|
||||
return Response.json(
|
||||
sprints.map((s) => ({
|
||||
...s,
|
||||
start_date: s.start_date ? s.start_date.toISOString().slice(0, 10) : null,
|
||||
end_date: s.end_date ? s.end_date.toISOString().slice(0, 10) : null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
110
app/api/sprints/[id]/workspace/route.ts
Normal file
110
app/api/sprints/[id]/workspace/route.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace
|
||||
//
|
||||
// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor
|
||||
// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via
|
||||
// product-membership.
|
||||
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { 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 sprint = await prisma.sprint.findFirst({
|
||||
where: { id, product: productAccessFilter(auth.userId) },
|
||||
select: {
|
||||
id: true,
|
||||
product_id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
created_at: true,
|
||||
completed_at: true,
|
||||
product: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
if (!sprint) {
|
||||
return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [stories, tasks] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
include: {
|
||||
tasks: { select: { id: true, status: true } },
|
||||
assignee: { select: { id: true, username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.task.findMany({
|
||||
where: { sprint_id: id },
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
description: true,
|
||||
priority: true,
|
||||
sort_order: true,
|
||||
status: true,
|
||||
story_id: true,
|
||||
sprint_id: true,
|
||||
created_at: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
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: sprint.product,
|
||||
sprint: {
|
||||
id: sprint.id,
|
||||
product_id: sprint.product_id,
|
||||
code: sprint.code,
|
||||
sprint_goal: sprint.sprint_goal,
|
||||
status: sprint.status,
|
||||
start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null,
|
||||
end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null,
|
||||
created_at: sprint.created_at,
|
||||
completed_at: sprint.completed_at,
|
||||
},
|
||||
stories: stories.map((s) => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
priority: s.priority,
|
||||
sort_order: s.sort_order,
|
||||
status: storyStatusToApi(s.status),
|
||||
pbi_id: s.pbi_id,
|
||||
sprint_id: s.sprint_id,
|
||||
created_at: s.created_at,
|
||||
taskCount: s.tasks.length,
|
||||
doneCount: s.tasks.filter((t) => t.status === 'DONE').length,
|
||||
assignee_id: s.assignee_id,
|
||||
assignee_username: s.assignee?.username ?? null,
|
||||
})),
|
||||
tasksByStory,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue