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:
parent
a8adac127f
commit
43a4294424
29 changed files with 809 additions and 116 deletions
24
app/api/health/route.ts
Normal file
24
app/api/health/route.ts
Normal 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)
|
||||
}
|
||||
94
app/api/products/[id]/claude-context/route.ts
Normal file
94
app/api/products/[id]/claude-context/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue