- 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>
187 lines
6 KiB
TypeScript
187 lines
6 KiB
TypeScript
import { authenticateApiRequest } from '@/lib/api-auth'
|
|
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.
|
|
// Reject it here until the sprint UI handles REVIEW so external clients don't
|
|
// drive tasks into a state the shared UI can't display.
|
|
const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review')
|
|
|
|
const VERIFY_REQUIRED_VALUES = ['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const
|
|
|
|
const patchSchema = z
|
|
.object({
|
|
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
|
|
implementation_plan: z.string().optional(),
|
|
verify_only: z.boolean().optional(),
|
|
verify_required: z.enum(VERIFY_REQUIRED_VALUES).optional(),
|
|
})
|
|
.refine(
|
|
(data) =>
|
|
data.status !== undefined ||
|
|
data.implementation_plan !== undefined ||
|
|
data.verify_only !== undefined ||
|
|
data.verify_required !== undefined,
|
|
{ message: 'Geef minimaal status, implementation_plan, verify_only of verify_required mee' },
|
|
)
|
|
|
|
export async function PATCH(
|
|
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 })
|
|
}
|
|
if (auth.isDemo) {
|
|
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
|
}
|
|
|
|
const { id } = await params
|
|
|
|
const task = await prisma.task.findFirst({
|
|
where: { id },
|
|
include: {
|
|
story: {
|
|
include: {
|
|
product: {
|
|
include: {
|
|
members: {
|
|
where: { user_id: auth.userId },
|
|
select: { id: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if (!task) {
|
|
return Response.json({ error: 'Taak niet gevonden' }, { status: 404 })
|
|
}
|
|
|
|
const hasAccess =
|
|
task.story.product.user_id === auth.userId ||
|
|
(task.story.product.members?.length ?? 0) > 0
|
|
if (!hasAccess) {
|
|
return Response.json({ error: 'Geen toegang' }, { status: 403 })
|
|
}
|
|
|
|
let body: unknown
|
|
try {
|
|
body = await request.json()
|
|
} catch {
|
|
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
|
|
}
|
|
const parsed = patchSchema.safeParse(body)
|
|
if (!parsed.success) {
|
|
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
|
|
}
|
|
|
|
let dbStatus: ReturnType<typeof taskStatusFromApi> | undefined
|
|
if (parsed.data.status !== undefined) {
|
|
dbStatus = taskStatusFromApi(parsed.data.status)
|
|
if (dbStatus === null) {
|
|
return Response.json(
|
|
{ error: { fieldErrors: { status: ['Onbekende status'] } } },
|
|
{ status: 422 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Combine simple field writes (plan, verify_only, verify_required) into one update call
|
|
const simpleData: { implementation_plan?: string; verify_only?: boolean; verify_required?: typeof VERIFY_REQUIRED_VALUES[number] } = {}
|
|
if (parsed.data.implementation_plan !== undefined)
|
|
simpleData.implementation_plan = parsed.data.implementation_plan
|
|
if (parsed.data.verify_only !== undefined)
|
|
simpleData.verify_only = parsed.data.verify_only
|
|
if (parsed.data.verify_required !== undefined)
|
|
simpleData.verify_required = parsed.data.verify_required
|
|
|
|
const updated = await prisma.$transaction(async (tx) => {
|
|
const simpleUpdate = Object.keys(simpleData).length > 0
|
|
? await tx.task.update({
|
|
where: { id },
|
|
data: simpleData,
|
|
select: { id: true, status: true, implementation_plan: true, verify_only: true, verify_required: true },
|
|
})
|
|
: null
|
|
|
|
if (dbStatus !== undefined && dbStatus !== null) {
|
|
const result = await propagateStatusUpwards(id, dbStatus, tx)
|
|
return {
|
|
id: result.task.id,
|
|
status: result.task.status,
|
|
implementation_plan: result.task.implementation_plan,
|
|
verify_only: simpleUpdate?.verify_only,
|
|
verify_required: simpleUpdate?.verify_required,
|
|
}
|
|
}
|
|
|
|
if (simpleUpdate) return simpleUpdate
|
|
|
|
// Should not reach here — patchSchema rejects bodies without recognized fields.
|
|
throw new Error('Geen wijzigingen')
|
|
})
|
|
|
|
return Response.json({
|
|
id: updated.id,
|
|
status: taskStatusToApi(updated.status),
|
|
implementation_plan: updated.implementation_plan,
|
|
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
|
|
...(updated.verify_required !== undefined && { verify_required: updated.verify_required }),
|
|
})
|
|
}
|