feat(PBI-79/ST-1335): sprint-membership-summary + cross-sprint-blocks endpoints

Twee nieuwe GET-route handlers, beide verplicht gescoped op pbiIds (geen
product-brede aanroepen).

- app/api/products/[id]/sprint-membership-summary/route.ts
  Response: { [pbiId]: { total, inSprint } } via twee prisma.groupBy calls
  (totaal + binnen actieve sprint). Voor state-B tri-state.

- app/api/products/[id]/cross-sprint-blocks/route.ts
  Response: { [storyId]: { sprintId, sprintName } } voor stories in andere
  OPEN sprints. UX-hint voor disabled-vinkjes; commit-acties blijven
  autoritatief.

Tests: 13 cases dekken happy path, 400 zonder pbiIds, 400 zonder sprintId,
404 zonder product-access, auth-fail, en NOT-clause voor excludeSprintId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 13:48:47 +02:00
parent b4a515e86e
commit e89fb7149f
4 changed files with 402 additions and 0 deletions

View file

@ -0,0 +1,74 @@
// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks
//
// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in
// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side
// commit-actions blijven autoritatief — dit endpoint is alleen voor UI.
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export const dynamic = 'force-dynamic'
function parsePbiIds(raw: string | null): string[] | null {
if (!raw) return null
const ids = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
return ids.length === 0 ? null : ids
}
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: productId } = await params
const url = new URL(request.url)
const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined
const pbiIds = parsePbiIds(url.searchParams.get('pbiIds'))
if (!pbiIds) {
return Response.json(
{ error: 'pbiIds is verplicht (comma-separated)' },
{ status: 400 },
)
}
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(auth.userId) },
select: { id: true },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const stories = await prisma.story.findMany({
where: {
pbi_id: { in: pbiIds },
product_id: productId,
sprint_id: { not: null },
...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}),
sprint: { status: 'OPEN' },
},
select: {
id: true,
sprint: { select: { id: true, code: true } },
},
})
const result: Record<string, { sprintId: string; sprintName: string }> = {}
for (const story of stories) {
if (!story.sprint) continue
result[story.id] = {
sprintId: story.sprint.id,
sprintName: story.sprint.code,
}
}
return Response.json(result)
}

View file

@ -0,0 +1,87 @@
// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary
//
// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds.
// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy
// + één count-by-sprint waar pbi_id IN (pbiIds).
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export const dynamic = 'force-dynamic'
function parsePbiIds(raw: string | null): string[] | null {
if (!raw) return null
const ids = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
return ids.length === 0 ? null : ids
}
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: productId } = await params
const url = new URL(request.url)
const sprintId = url.searchParams.get('sprintId')
const pbiIds = parsePbiIds(url.searchParams.get('pbiIds'))
if (!sprintId) {
return Response.json({ error: 'sprintId is verplicht' }, { status: 400 })
}
if (!pbiIds) {
return Response.json(
{ error: 'pbiIds is verplicht (comma-separated)' },
{ status: 400 },
)
}
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(auth.userId) },
select: { id: true },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const [totals, inSprint] = await Promise.all([
prisma.story.groupBy({
by: ['pbi_id'],
where: { pbi_id: { in: pbiIds }, product_id: productId },
_count: { _all: true },
}),
prisma.story.groupBy({
by: ['pbi_id'],
where: {
pbi_id: { in: pbiIds },
product_id: productId,
sprint_id: sprintId,
},
_count: { _all: true },
}),
])
const inSprintByPbi = new Map<string, number>()
for (const row of inSprint) {
inSprintByPbi.set(row.pbi_id, row._count._all)
}
const result: Record<string, { total: number; inSprint: number }> = {}
for (const pbiId of pbiIds) {
result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 }
}
for (const row of totals) {
result[row.pbi_id] = {
total: row._count._all,
inSprint: inSprintByPbi.get(row.pbi_id) ?? 0,
}
}
return Response.json(result)
}