From fa8b21ebe4a230c1e9e469c6e05a2300cfde89e1 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 06:46:10 +0200 Subject: [PATCH] feat(PBI-74): read-routes voor sprint-workspace + cache-headers (Story 9 / T-882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- app/api/products/[id]/sprints/route.ts | 59 +++++++++++++ app/api/sprints/[id]/workspace/route.ts | 110 ++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 app/api/products/[id]/sprints/route.ts create mode 100644 app/api/sprints/[id]/workspace/route.ts diff --git a/app/api/products/[id]/sprints/route.ts b/app/api/products/[id]/sprints/route.ts new file mode 100644 index 0000000..50f8bb8 --- /dev/null +++ b/app/api/products/[id]/sprints/route.ts @@ -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, + })), + ) +} diff --git a/app/api/sprints/[id]/workspace/route.ts b/app/api/sprints/[id]/workspace/route.ts new file mode 100644 index 0000000..e3a19ab --- /dev/null +++ b/app/api/sprints/[id]/workspace/route.ts @@ -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 = {} + 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, + }) +}