Todo description, entity codes, REST API extensions and Claude Code hardening (ST-509/511/512/513) (#2)

* docs(ST-511): add backlog entry for entity codes feature

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ST-511): add createWithCodeRetry helper to handle P2002 race on auto codes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ST-511): retry on auto-code unique conflict in story and pbi create

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ST-511): surface field errors for code and title in PBI dialog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ST-511): read create-state errors in Story dialog fieldError

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-512): add backlog entry for REST API code/description/implementation_plan extensions; mark ST-511 done

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-512): extend REST API with code, description and implementation_plan

- GET /api/products returns code, description and definition_of_done
- GET /api/products/:id/next-story returns story.code and per-task code + implementation_plan
- GET /api/sprints/:id/tasks returns description, implementation_plan, story_code and derived per-task code
- POST /api/todos accepts and returns optional description (max 2000)

All changes are additive — existing clients ignore unknown keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-512): mark ST-512 as done

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-513): add backlog entry for API hardening for Claude Code

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-513): add task and story status mappers for API boundary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-513): expose lowercase status on API and accept lowercase in PATCH /api/tasks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-513): add metadata JSONB column to StoryLog

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-513): accept optional metadata in story log and switch validation errors to 422

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-513): add GET /api/health endpoint with optional db ping

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-513): add GET /api/products/:id/claude-context bundled endpoint

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-513): add docs/API.md and link from CLAUDE.md specs table

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-513): mark ST-513 as done

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ST-513): split 400 (malformed JSON) from 422 (validation), reject 'review'

Codex review on PR #2:

- P2.1: routes treated JSON parse failures as 422 instead of 400, breaking
  the contract in docs/API.md. Replace `request.json().catch(() => null)`
  with try/catch in 4 routes (tasks, reorder, todos, story-log) so a
  malformed body returns 400 and only well-formed-but-invalid bodies
  return 422.

- P2.2: PATCH /api/tasks/:id accepted `status: "review"`, but the sprint
  task list UI does not render REVIEW (no label/color, the cycle helper
  falls back to TO_DO). Reject `review` at the API until the sprint UI
  is extended; document the subset in docs/API.md.

Tests in __tests__/api updated for the new contract (29 assertions:
zod-failures now expect 422, status payloads use lowercase API values,
sprint-tasks mocks include the new story relation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 23:40:54 +02:00 committed by GitHub
parent a8adac127f
commit 43a4294424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 809 additions and 116 deletions

24
app/api/health/route.ts Normal file
View file

@ -0,0 +1,24 @@
import packageJson from '@/package.json'
import { prisma } from '@/lib/prisma'
export async function GET(request: Request) {
const url = new URL(request.url)
const checkDb = url.searchParams.get('db') === '1'
const body: Record<string, unknown> = {
status: 'ok',
version: packageJson.version,
time: new Date().toISOString(),
}
if (checkDb) {
try {
await prisma.$queryRaw`SELECT 1`
body.database = 'ok'
} catch {
body.database = 'down'
}
}
return Response.json(body)
}

View file

@ -0,0 +1,94 @@
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 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,
code: true,
name: true,
description: true,
repo_url: true,
definition_of_done: true,
},
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const [activeSprint, openTodos] = await Promise.all([
prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
select: { id: true, sprint_goal: true, status: true },
}),
prisma.todo.findMany({
where: { user_id: auth.userId, product_id: id, done: false, archived: false },
select: { id: true, title: true, description: true, created_at: true },
orderBy: { created_at: 'asc' },
take: 50,
}),
])
let nextStoryPayload: unknown = null
if (activeSprint) {
const story = await prisma.story.findFirst({
where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
include: {
tasks: {
orderBy: { sort_order: 'asc' },
select: {
id: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
sort_order: true,
status: true,
},
},
},
})
if (story) {
nextStoryPayload = {
id: story.id,
code: story.code,
title: story.title,
description: story.description,
acceptance_criteria: story.acceptance_criteria,
priority: story.priority,
status: storyStatusToApi(story.status),
tasks: story.tasks.map((t, idx) => ({
id: t.id,
code: story.code ? `${story.code}.${idx + 1}` : null,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: taskStatusToApi(t.status),
})),
}
}
}
return Response.json({
product,
active_sprint: activeSprint,
next_story: nextStoryPayload,
open_todos: openTodos,
})
}

View file

@ -1,6 +1,7 @@
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 async function GET(
request: Request,
@ -25,8 +26,16 @@ export async function GET(
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
include: {
tasks: {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
select: { id: true, title: true, description: true, priority: true, sort_order: true, status: true },
orderBy: { sort_order: 'asc' },
select: {
id: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
sort_order: true,
status: true,
},
},
},
})
@ -35,11 +44,24 @@ export async function GET(
return Response.json({ error: 'Geen open stories in de Sprint' }, { status: 404 })
}
const tasks = story.tasks.map((t, idx) => ({
id: t.id,
code: story.code ? `${story.code}.${idx + 1}` : null,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: taskStatusToApi(t.status),
}))
return Response.json({
id: story.id,
code: story.code,
title: story.title,
description: story.description,
acceptance_criteria: story.acceptance_criteria,
tasks: story.tasks,
status: storyStatusToApi(story.status),
tasks,
})
}

View file

@ -11,7 +11,7 @@ export async function GET(request: Request) {
const products = await prisma.product.findMany({
where: { archived: false, ...productAccessFilter(auth.userId) },
orderBy: { created_at: 'desc' },
select: { id: true, name: true, repo_url: true },
select: { id: true, code: true, name: true, description: true, repo_url: true, definition_of_done: true },
})
return Response.json(products)

View file

@ -27,11 +27,11 @@ export async function POST(request: Request) {
}
if (!ALLOWED_TYPES.has(file.type)) {
return Response.json({ error: 'Alleen JPEG, PNG en WebP zijn toegestaan' }, { status: 400 })
return Response.json({ error: 'Alleen JPEG, PNG en WebP zijn toegestaan' }, { status: 422 })
}
if (file.size > MAX_BYTES) {
return Response.json({ error: 'Bestand is groter dan 12 MB' }, { status: 400 })
return Response.json({ error: 'Bestand is groter dan 12 MB' }, { status: 422 })
}
const input = Buffer.from(await file.arrayBuffer())

View file

@ -1,6 +1,7 @@
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { taskStatusToApi } from '@/lib/task-status'
export async function GET(
request: Request,
@ -31,8 +32,40 @@ export async function GET(
{ sort_order: 'asc' },
],
take: limit,
select: { id: true, title: true, story_id: true, priority: true, sort_order: true, status: true },
select: {
id: true,
title: true,
description: true,
implementation_plan: true,
story_id: true,
priority: true,
sort_order: true,
status: true,
story: {
select: {
code: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
},
},
},
})
return Response.json(tasks)
const enriched = tasks.map((t) => {
const positionInStory = t.story.tasks.findIndex((st) => st.id === t.id)
const code = t.story.code && positionInStory >= 0 ? `${t.story.code}.${positionInStory + 1}` : null
return {
id: t.id,
code,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
story_id: t.story_id,
story_code: t.story.code,
priority: t.priority,
sort_order: t.sort_order,
status: taskStatusToApi(t.status),
}
})
return Response.json(enriched)
}

View file

@ -1,23 +1,29 @@
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { Prisma } from '@prisma/client'
import { z } from 'zod'
const metadataField = z.record(z.string(), z.unknown()).optional()
const logSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('IMPLEMENTATION_PLAN'),
content: z.string().min(1),
metadata: metadataField,
}),
z.object({
type: z.literal('TEST_RESULT'),
content: z.string().min(1),
status: z.enum(['PASSED', 'FAILED']),
metadata: metadataField,
}),
z.object({
type: z.literal('COMMIT'),
content: z.string().min(1),
commit_hash: z.string().min(1),
commit_message: z.string().min(1),
metadata: metadataField,
}),
])
@ -42,10 +48,15 @@ export async function POST(
return Response.json({ error: 'Story niet gevonden' }, { status: 404 })
}
const body = await request.json().catch(() => null)
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = logSchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
const log = await prisma.storyLog.create({
@ -56,6 +67,9 @@ export async function POST(
status: 'status' in parsed.data ? parsed.data.status : null,
commit_hash: 'commit_hash' in parsed.data ? parsed.data.commit_hash : null,
commit_message: 'commit_message' in parsed.data ? parsed.data.commit_message : null,
metadata: parsed.data.metadata
? (parsed.data.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
})

View file

@ -21,10 +21,15 @@ export async function PATCH(
const { id: storyId } = await params
const body = await request.json().catch(() => null)
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = bodySchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
const story = await prisma.story.findFirst({
@ -38,7 +43,7 @@ export async function PATCH(
const storyTaskIds = new Set(story.tasks.map(t => t.id))
const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id))
if (invalidId) {
return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 400 })
return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 })
}
await prisma.$transaction(

View file

@ -1,10 +1,17 @@
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'
// `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 patchSchema = z
.object({
status: z.enum(['TO_DO', 'IN_PROGRESS', 'DONE']).optional(),
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
implementation_plan: z.string().optional(),
})
.refine((data) => data.status !== undefined || data.implementation_plan !== undefined, {
@ -53,16 +60,32 @@ export async function PATCH(
return Response.json({ error: 'Geen toegang' }, { status: 403 })
}
const body = await request.json().catch(() => null)
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: 400 })
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 },
)
}
}
const updated = await prisma.task.update({
where: { id },
data: {
...(parsed.data.status !== undefined && { status: parsed.data.status }),
...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }),
...(parsed.data.implementation_plan !== undefined && {
implementation_plan: parsed.data.implementation_plan,
}),
@ -71,7 +94,7 @@ export async function PATCH(
return Response.json({
id: updated.id,
status: updated.status,
status: taskStatusToApi(updated.status),
implementation_plan: updated.implementation_plan,
})
}

View file

@ -4,6 +4,7 @@ import { z } from 'zod'
const bodySchema = z.object({
title: z.string().min(1, 'Titel is verplicht').max(500),
description: z.string().max(2000, 'Beschrijving mag maximaal 2000 tekens bevatten').optional(),
product_id: z.string().min(1, 'Product is verplicht'),
})
@ -16,10 +17,15 @@ export async function POST(request: Request) {
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
}
const body = await request.json().catch(() => null)
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = bodySchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
const product = await prisma.product.findFirst({
@ -29,13 +35,19 @@ export async function POST(request: Request) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const description = parsed.data.description?.trim() || null
const todo = await prisma.todo.create({
data: {
user_id: auth.userId,
product_id: parsed.data.product_id,
title: parsed.data.title,
description,
},
})
return Response.json({ id: todo.id, title: todo.title, created_at: todo.created_at }, { status: 201 })
return Response.json(
{ id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at },
{ status: 201 },
)
}