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
|
|
@ -8,7 +8,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
|
||||
import { generateNextPbiCode } from '@/lib/code-server'
|
||||
import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -49,14 +49,12 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
|||
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||
if (!product) return { error: 'Product niet gevonden' }
|
||||
|
||||
let code = normalizeCode(parsed.data.code)
|
||||
if (code !== null && !isValidCode(code)) {
|
||||
const manualCode = normalizeCode(parsed.data.code)
|
||||
if (manualCode !== null && !isValidCode(manualCode)) {
|
||||
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
|
||||
}
|
||||
if (code === null) {
|
||||
code = await generateNextPbiCode(parsed.data.productId)
|
||||
} else {
|
||||
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code } })
|
||||
if (manualCode) {
|
||||
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } })
|
||||
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
|
||||
}
|
||||
|
||||
|
|
@ -66,16 +64,29 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
|
|||
})
|
||||
const sort_order = (last?.sort_order ?? 0) + 1.0
|
||||
|
||||
const pbi = await prisma.pbi.create({
|
||||
data: {
|
||||
product_id: parsed.data.productId,
|
||||
code,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
priority: parsed.data.priority,
|
||||
sort_order,
|
||||
},
|
||||
})
|
||||
const insert = (code: string | null) =>
|
||||
prisma.pbi.create({
|
||||
data: {
|
||||
product_id: parsed.data.productId,
|
||||
code,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
priority: parsed.data.priority,
|
||||
sort_order,
|
||||
},
|
||||
})
|
||||
|
||||
let pbi
|
||||
try {
|
||||
pbi = manualCode
|
||||
? await insert(manualCode)
|
||||
: await createWithCodeRetry(
|
||||
() => generateNextPbiCode(parsed.data.productId),
|
||||
(code) => insert(code),
|
||||
)
|
||||
} catch {
|
||||
return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } }
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${parsed.data.productId}`)
|
||||
return { success: true, pbi }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
|
|||
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
|
||||
import { requireProductWriter } from '@/lib/auth'
|
||||
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
|
||||
import { generateNextStoryCode } from '@/lib/code-server'
|
||||
import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -68,14 +68,12 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
|
|||
})
|
||||
if (!pbi) return { error: 'PBI niet gevonden' }
|
||||
|
||||
let code = normalizeCode(parsed.data.code)
|
||||
if (code !== null && !isValidCode(code)) {
|
||||
const manualCode = normalizeCode(parsed.data.code)
|
||||
if (manualCode !== null && !isValidCode(manualCode)) {
|
||||
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
|
||||
}
|
||||
if (code === null) {
|
||||
code = await generateNextStoryCode(pbi.product_id)
|
||||
} else {
|
||||
const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code } })
|
||||
if (manualCode) {
|
||||
const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } })
|
||||
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
|
||||
}
|
||||
|
||||
|
|
@ -85,19 +83,32 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
|
|||
})
|
||||
const sort_order = (last?.sort_order ?? 0) + 1.0
|
||||
|
||||
const story = await prisma.story.create({
|
||||
data: {
|
||||
pbi_id: parsed.data.pbiId,
|
||||
product_id: pbi.product_id,
|
||||
code,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
||||
priority: parsed.data.priority,
|
||||
sort_order,
|
||||
status: 'OPEN',
|
||||
},
|
||||
})
|
||||
const insert = (code: string | null) =>
|
||||
prisma.story.create({
|
||||
data: {
|
||||
pbi_id: parsed.data.pbiId,
|
||||
product_id: pbi.product_id,
|
||||
code,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
acceptance_criteria: parsed.data.acceptance_criteria ?? null,
|
||||
priority: parsed.data.priority,
|
||||
sort_order,
|
||||
status: 'OPEN',
|
||||
},
|
||||
})
|
||||
|
||||
let story
|
||||
try {
|
||||
story = manualCode
|
||||
? await insert(manualCode)
|
||||
: await createWithCodeRetry(
|
||||
() => generateNextStoryCode(pbi.product_id),
|
||||
(code) => insert(code),
|
||||
)
|
||||
} catch {
|
||||
return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } }
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${pbi.product_id}`)
|
||||
return { success: true, story }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue