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

View file

@ -21,6 +21,7 @@ Lees het relevante document voordat je aan een feature begint. Nooit gokken over
| `docs/scrum4me-backlog.md` | Welke task bouwen, volgorde, "done when"-criteria | | `docs/scrum4me-backlog.md` | Welke task bouwen, volgorde, "done when"-criteria |
| `docs/scrum4me-personas.md` | Lars (primair), Dina, Remi — gebruik bij UI-beslissingen | | `docs/scrum4me-personas.md` | Lars (primair), Dina, Remi — gebruik bij UI-beslissingen |
| `docs/scrum4me-product-backlog.md` | Historische domein-backlog (referentie); seed wordt sinds ST-004 gegenereerd uit `scrum4me-backlog.md` via `prisma/seed-data/parse-backlog.ts` | | `docs/scrum4me-product-backlog.md` | Historische domein-backlog (referentie); seed wordt sinds ST-004 gegenereerd uit `scrum4me-backlog.md` via `prisma/seed-data/parse-backlog.ts` |
| `docs/API.md` | REST-API contract voor Claude Code — endpoints, status-enums, foutcodes, voorbeeld-curls |
| `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen | | `docs/scrum4me-styling.md` | **Lees dit voor elk component** — MD3-kleuren, shadcn patronen |
| `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen | | `docs/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen |

View file

@ -92,7 +92,7 @@ describe('GET /api/products/:id/next-story', () => {
acceptance_criteria: '- Gebruikersnaam verplicht', acceptance_criteria: '- Gebruikersnaam verplicht',
}) })
expect(data.tasks).toHaveLength(2) expect(data.tasks).toHaveLength(2)
expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'TO_DO' }) expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' })
}) })
it('queries story ordered by priority then sort_order', async () => { it('queries story ordered by priority then sort_order', async () => {

View file

@ -55,32 +55,32 @@ describe('PATCH /api/stories/:id/tasks/reorder', () => {
}) })
// TC-RO-06 — body validation fires before story lookup // TC-RO-06 — body validation fires before story lookup
it('returns 400 when task_ids is an empty array', async () => { it('returns 422 when task_ids is an empty array', async () => {
const res = await patchReorder(...makeRequest({ task_ids: [] })) const res = await patchReorder(...makeRequest({ task_ids: [] }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
}) })
// TC-RO-07 // TC-RO-07
it('returns 400 when task_ids is not an array', async () => { it('returns 422 when task_ids is not an array', async () => {
const res = await patchReorder(...makeRequest({ task_ids: 'task-1' })) const res = await patchReorder(...makeRequest({ task_ids: 'task-1' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() expect(mockPrisma.story.findFirst).not.toHaveBeenCalled()
}) })
it('returns 400 when task_ids is missing entirely', async () => { it('returns 422 when task_ids is missing entirely', async () => {
const res = await patchReorder(...makeRequest({})) const res = await patchReorder(...makeRequest({}))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-RO-08 // TC-RO-08
it('returns 400 when task_ids contains an ID not belonging to the story', async () => { it('returns 422 when task_ids contains an ID not belonging to the story', async () => {
mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2']))
const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] })) const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] }))
const data = await res.json() const data = await res.json()
expect(res.status).toBe(400) expect(res.status).toBe(422)
expect(data.error).toContain('task-from-other-story') expect(data.error).toContain('task-from-other-story')
}) })

View file

@ -389,7 +389,7 @@ describe('PATCH /api/tasks/:id', () => {
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' })
const res = await patchTask( const res = await patchTask(
makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
routeCtx('task-1') routeCtx('task-1')
) )
expect(res.status).toBe(200) expect(res.status).toBe(200)

View file

@ -28,7 +28,20 @@ const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
function makeTask(n: number) { function makeTask(n: number) {
return { id: `task-${n}`, title: `Task ${n}`, story_id: 'story-1', priority: 1, sort_order: n, status: 'TO_DO' } return {
id: `task-${n}`,
title: `Task ${n}`,
description: null,
implementation_plan: null,
story_id: 'story-1',
priority: 1,
sort_order: n,
status: 'TO_DO',
story: {
code: 'ST-1',
tasks: [{ id: `task-${n}` }],
},
}
} }
function makeRequest(sprintId = 'sprint-1', limit?: number): [Request, { params: Promise<{ id: string }> }] { function makeRequest(sprintId = 'sprint-1', limit?: number): [Request, { params: Promise<{ id: string }> }] {
@ -124,7 +137,7 @@ describe('GET /api/sprints/:id/tasks', () => {
story_id: 'story-1', story_id: 'story-1',
priority: 1, priority: 1,
sort_order: 1, sort_order: 1,
status: 'TO_DO', status: 'todo',
}) })
}) })
}) })

View file

@ -48,27 +48,27 @@ describe('POST /api/stories/:id/log', () => {
}) })
// TC-L-06 // TC-L-06
it('returns 400 when type field is missing', async () => { it('returns 422 when type field is missing', async () => {
const res = await postStoryLog(...makeRequest({ content: 'Missing type' })) const res = await postStoryLog(...makeRequest({ content: 'Missing type' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-L-07 // TC-L-07
it('returns 400 for unknown type value', async () => { it('returns 422 for unknown type value', async () => {
const res = await postStoryLog(...makeRequest({ type: 'UNKNOWN', content: 'test' })) const res = await postStoryLog(...makeRequest({ type: 'UNKNOWN', content: 'test' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
describe('type: IMPLEMENTATION_PLAN', () => { describe('type: IMPLEMENTATION_PLAN', () => {
// TC-L-08 // TC-L-08
it('returns 400 when content is missing', async () => { it('returns 422 when content is missing', async () => {
const res = await postStoryLog(...makeRequest({ type: 'IMPLEMENTATION_PLAN' })) const res = await postStoryLog(...makeRequest({ type: 'IMPLEMENTATION_PLAN' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
it('returns 400 when content is empty string', async () => { it('returns 422 when content is empty string', async () => {
const res = await postStoryLog(...makeRequest({ type: 'IMPLEMENTATION_PLAN', content: '' })) const res = await postStoryLog(...makeRequest({ type: 'IMPLEMENTATION_PLAN', content: '' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-L-09 // TC-L-09
@ -95,17 +95,17 @@ describe('POST /api/stories/:id/log', () => {
describe('type: TEST_RESULT', () => { describe('type: TEST_RESULT', () => {
// TC-L-10 // TC-L-10
it('returns 400 when status is missing', async () => { it('returns 422 when status is missing', async () => {
const res = await postStoryLog(...makeRequest({ type: 'TEST_RESULT', content: 'Tests done' })) const res = await postStoryLog(...makeRequest({ type: 'TEST_RESULT', content: 'Tests done' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-L-11 // TC-L-11
it('returns 400 for invalid status value', async () => { it('returns 422 for invalid status value', async () => {
const res = await postStoryLog( const res = await postStoryLog(
...makeRequest({ type: 'TEST_RESULT', content: 'Tests done', status: 'UNKNOWN' }) ...makeRequest({ type: 'TEST_RESULT', content: 'Tests done', status: 'UNKNOWN' })
) )
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-L-12 // TC-L-12
@ -142,19 +142,19 @@ describe('POST /api/stories/:id/log', () => {
describe('type: COMMIT', () => { describe('type: COMMIT', () => {
// TC-L-14 // TC-L-14
it('returns 400 when commit_hash is missing', async () => { it('returns 422 when commit_hash is missing', async () => {
const res = await postStoryLog( const res = await postStoryLog(
...makeRequest({ type: 'COMMIT', content: 'feat: done', commit_message: 'feat: ST-001' }) ...makeRequest({ type: 'COMMIT', content: 'feat: done', commit_message: 'feat: ST-001' })
) )
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-L-15 // TC-L-15
it('returns 400 when commit_message is missing', async () => { it('returns 422 when commit_message is missing', async () => {
const res = await postStoryLog( const res = await postStoryLog(
...makeRequest({ type: 'COMMIT', content: 'feat: done', commit_hash: 'abc1234' }) ...makeRequest({ type: 'COMMIT', content: 'feat: done', commit_hash: 'abc1234' })
) )
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-L-16 // TC-L-16

View file

@ -59,31 +59,31 @@ describe('PATCH /api/tasks/:id', () => {
}) })
// TC-T-06 // TC-T-06
it('returns 400 for invalid status value', async () => { it('returns 422 for invalid status value', async () => {
const res = await patchTask(...makeRequest({ status: 'INVALID' })) const res = await patchTask(...makeRequest({ status: 'INVALID' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-T-07 // TC-T-07
it('returns 400 when body has no recognized fields', async () => { it('returns 422 when body has no recognized fields', async () => {
const res = await patchTask(...makeRequest({})) const res = await patchTask(...makeRequest({}))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
it('returns 400 when body has only unrecognized fields', async () => { it('returns 422 when body has only unrecognized fields', async () => {
const res = await patchTask(...makeRequest({ unknown_field: 'value' })) const res = await patchTask(...makeRequest({ unknown_field: 'value' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-T-08 // TC-T-08
it('updates status only and returns 200 with updated task', async () => { it('updates status only and returns 200 with updated task', async () => {
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS', implementation_plan: null }) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS', implementation_plan: null })
const res = await patchTask(...makeRequest({ status: 'IN_PROGRESS' })) const res = await patchTask(...makeRequest({ status: 'in_progress' }))
const data = await res.json() const data = await res.json()
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(data).toMatchObject({ id: 'task-1', status: 'IN_PROGRESS' }) expect(data).toMatchObject({ id: 'task-1', status: 'in_progress' })
expect(mockPrisma.task.update).toHaveBeenCalledWith( expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: { status: 'IN_PROGRESS' }, data: { status: 'IN_PROGRESS' },
@ -113,11 +113,11 @@ describe('PATCH /api/tasks/:id', () => {
const plan = 'Full plan here.' const plan = 'Full plan here.'
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan }) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan })
const res = await patchTask(...makeRequest({ status: 'DONE', implementation_plan: plan })) const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan }))
const data = await res.json() const data = await res.json()
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(data).toMatchObject({ status: 'DONE', implementation_plan: plan }) expect(data).toMatchObject({ status: 'done', implementation_plan: plan })
expect(mockPrisma.task.update).toHaveBeenCalledWith( expect(mockPrisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
data: { status: 'DONE', implementation_plan: plan }, data: { status: 'DONE', implementation_plan: plan },
@ -127,20 +127,32 @@ describe('PATCH /api/tasks/:id', () => {
// TC-T-11 // TC-T-11
it('allows update when user is a team member (not product owner)', async () => { it('allows update when user is a team member (not product owner)', async () => {
// task belongs to user-2's product, but user-1 is a member
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.task.findFirst.mockResolvedValue(makeTask({ userId: 'user-2', membersLength: 1 })) mockPrisma.task.findFirst.mockResolvedValue(makeTask({ userId: 'user-2', membersLength: 1 }))
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: null }) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: null })
const res = await patchTask(...makeRequest({ status: 'DONE' })) const res = await patchTask(...makeRequest({ status: 'done' }))
expect(res.status).toBe(200) expect(res.status).toBe(200)
}) })
it('all three valid status values are accepted', async () => { it('the three patchable status values are accepted (review is rejected)', async () => {
for (const status of ['TO_DO', 'IN_PROGRESS', 'DONE'] as const) { for (const apiStatus of ['todo', 'in_progress', 'done'] as const) {
mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status, implementation_plan: null }) const dbStatus = { todo: 'TO_DO', in_progress: 'IN_PROGRESS', done: 'DONE' }[apiStatus]
const res = await patchTask(...makeRequest({ status })) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: dbStatus, implementation_plan: null })
const res = await patchTask(...makeRequest({ status: apiStatus }))
expect(res.status).toBe(200) expect(res.status).toBe(200)
} }
const reviewRes = await patchTask(...makeRequest({ status: 'review' }))
expect(reviewRes.status).toBe(422)
})
it('returns 400 for malformed JSON', async () => {
const req = new Request('http://localhost/api/tasks/task-1', {
method: 'PATCH',
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
body: '{not json',
})
const res = await patchTask(req, { params: Promise.resolve({ id: 'task-1' }) })
expect(res.status).toBe(400)
}) })
}) })

View file

@ -45,26 +45,26 @@ describe('POST /api/todos', () => {
}) })
// TC-TD-04 // TC-TD-04
it('returns 400 when title is missing', async () => { it('returns 422 when title is missing', async () => {
const res = await postTodo(makeRequest({ product_id: 'prod-1' })) const res = await postTodo(makeRequest({ product_id: 'prod-1' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-TD-05 // TC-TD-05
it('returns 400 when title is empty string', async () => { it('returns 422 when title is empty string', async () => {
const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' })) const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
it('returns 400 when product_id is missing', async () => { it('returns 422 when product_id is missing', async () => {
// product_id is required by the Zod schema (z.string().min(1)) // product_id is required by the Zod schema (z.string().min(1))
const res = await postTodo(makeRequest({ title: 'My todo' })) const res = await postTodo(makeRequest({ title: 'My todo' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
it('returns 400 when product_id is empty string', async () => { it('returns 422 when product_id is empty string', async () => {
const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' })) const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' }))
expect(res.status).toBe(400) expect(res.status).toBe(422)
}) })
// TC-TD-07 // TC-TD-07

View file

@ -8,7 +8,7 @@ import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct } from '@/lib/product-access' import { getAccessibleProduct } from '@/lib/product-access'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' 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() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) 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) const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden' } if (!product) return { error: 'Product niet gevonden' }
let code = normalizeCode(parsed.data.code) const manualCode = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) { if (manualCode !== null && !isValidCode(manualCode)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
} }
if (code === null) { if (manualCode) {
code = await generateNextPbiCode(parsed.data.productId) const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } })
} else {
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code } })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
} }
@ -66,7 +64,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
}) })
const sort_order = (last?.sort_order ?? 0) + 1.0 const sort_order = (last?.sort_order ?? 0) + 1.0
const pbi = await prisma.pbi.create({ const insert = (code: string | null) =>
prisma.pbi.create({
data: { data: {
product_id: parsed.data.productId, product_id: parsed.data.productId,
code, code,
@ -77,6 +76,18 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
}, },
}) })
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}`) revalidatePath(`/products/${parsed.data.productId}`)
return { success: true, pbi } return { success: true, pbi }
} }

View file

@ -9,7 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
import { requireProductWriter } from '@/lib/auth' import { requireProductWriter } from '@/lib/auth'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' 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() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) 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' } if (!pbi) return { error: 'PBI niet gevonden' }
let code = normalizeCode(parsed.data.code) const manualCode = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) { if (manualCode !== null && !isValidCode(manualCode)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } }
} }
if (code === null) { if (manualCode) {
code = await generateNextStoryCode(pbi.product_id) const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } })
} else {
const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code } })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
} }
@ -85,7 +83,8 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
}) })
const sort_order = (last?.sort_order ?? 0) + 1.0 const sort_order = (last?.sort_order ?? 0) + 1.0
const story = await prisma.story.create({ const insert = (code: string | null) =>
prisma.story.create({
data: { data: {
pbi_id: parsed.data.pbiId, pbi_id: parsed.data.pbiId,
product_id: pbi.product_id, product_id: pbi.product_id,
@ -99,6 +98,18 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
}, },
}) })
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}`) revalidatePath(`/products/${pbi.product_id}`)
return { success: true, story } return { success: true, story }
} }

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 { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status'
export async function GET( export async function GET(
request: Request, request: Request,
@ -25,8 +26,16 @@ export async function GET(
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
include: { include: {
tasks: { tasks: {
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], orderBy: { sort_order: 'asc' },
select: { id: true, title: true, description: true, priority: true, sort_order: true, status: true }, 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 }) 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({ return Response.json({
id: story.id, id: story.id,
code: story.code,
title: story.title, title: story.title,
description: story.description, description: story.description,
acceptance_criteria: story.acceptance_criteria, 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({ const products = await prisma.product.findMany({
where: { archived: false, ...productAccessFilter(auth.userId) }, where: { archived: false, ...productAccessFilter(auth.userId) },
orderBy: { created_at: 'desc' }, 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) return Response.json(products)

View file

@ -27,11 +27,11 @@ export async function POST(request: Request) {
} }
if (!ALLOWED_TYPES.has(file.type)) { 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) { 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()) const input = Buffer.from(await file.arrayBuffer())

View file

@ -1,6 +1,7 @@
import { authenticateApiRequest } from '@/lib/api-auth' import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { taskStatusToApi } from '@/lib/task-status'
export async function GET( export async function GET(
request: Request, request: Request,
@ -31,8 +32,40 @@ export async function GET(
{ sort_order: 'asc' }, { sort_order: 'asc' },
], ],
take: limit, 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 { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { Prisma } from '@prisma/client'
import { z } from 'zod' import { z } from 'zod'
const metadataField = z.record(z.string(), z.unknown()).optional()
const logSchema = z.discriminatedUnion('type', [ const logSchema = z.discriminatedUnion('type', [
z.object({ z.object({
type: z.literal('IMPLEMENTATION_PLAN'), type: z.literal('IMPLEMENTATION_PLAN'),
content: z.string().min(1), content: z.string().min(1),
metadata: metadataField,
}), }),
z.object({ z.object({
type: z.literal('TEST_RESULT'), type: z.literal('TEST_RESULT'),
content: z.string().min(1), content: z.string().min(1),
status: z.enum(['PASSED', 'FAILED']), status: z.enum(['PASSED', 'FAILED']),
metadata: metadataField,
}), }),
z.object({ z.object({
type: z.literal('COMMIT'), type: z.literal('COMMIT'),
content: z.string().min(1), content: z.string().min(1),
commit_hash: z.string().min(1), commit_hash: z.string().min(1),
commit_message: 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 }) 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) const parsed = logSchema.safeParse(body)
if (!parsed.success) { 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({ const log = await prisma.storyLog.create({
@ -56,6 +67,9 @@ export async function POST(
status: 'status' in parsed.data ? parsed.data.status : null, status: 'status' in parsed.data ? parsed.data.status : null,
commit_hash: 'commit_hash' in parsed.data ? parsed.data.commit_hash : 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, 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 { 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) const parsed = bodySchema.safeParse(body)
if (!parsed.success) { 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({ 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 storyTaskIds = new Set(story.tasks.map(t => t.id))
const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id)) const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id))
if (invalidId) { 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( await prisma.$transaction(

View file

@ -1,10 +1,17 @@
import { authenticateApiRequest } from '@/lib/api-auth' import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { z } from 'zod' 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 const patchSchema = z
.object({ .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(), implementation_plan: z.string().optional(),
}) })
.refine((data) => data.status !== undefined || data.implementation_plan !== undefined, { .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 }) 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) const parsed = patchSchema.safeParse(body)
if (!parsed.success) { 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({ const updated = await prisma.task.update({
where: { id }, where: { id },
data: { data: {
...(parsed.data.status !== undefined && { status: parsed.data.status }), ...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }),
...(parsed.data.implementation_plan !== undefined && { ...(parsed.data.implementation_plan !== undefined && {
implementation_plan: parsed.data.implementation_plan, implementation_plan: parsed.data.implementation_plan,
}), }),
@ -71,7 +94,7 @@ export async function PATCH(
return Response.json({ return Response.json({
id: updated.id, id: updated.id,
status: updated.status, status: taskStatusToApi(updated.status),
implementation_plan: updated.implementation_plan, implementation_plan: updated.implementation_plan,
}) })
} }

View file

@ -4,6 +4,7 @@ import { z } from 'zod'
const bodySchema = z.object({ const bodySchema = z.object({
title: z.string().min(1, 'Titel is verplicht').max(500), 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'), 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 }) 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) const parsed = bodySchema.safeParse(body)
if (!parsed.success) { 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({ 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 }) return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
} }
const description = parsed.data.description?.trim() || null
const todo = await prisma.todo.create({ const todo = await prisma.todo.create({
data: { data: {
user_id: auth.userId, user_id: auth.userId,
product_id: parsed.data.product_id, product_id: parsed.data.product_id,
title: parsed.data.title, 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 },
)
} }

View file

@ -79,9 +79,13 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
undefined undefined
) )
const error = isEdit const activeState = isEdit ? updateState : createState
? (typeof updateState?.error === 'string' ? updateState.error : null) const error = typeof activeState?.error === 'string' ? activeState.error : null
: (typeof createState?.error === 'string' ? createState.error : null) const fieldError = (field: string) => {
const err = activeState?.error
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
const titleRef = useRef<HTMLInputElement>(null) const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
@ -111,8 +115,9 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
defaultValue={pbi?.code ?? ''} defaultValue={pbi?.code ?? ''}
placeholder={isEdit ? '' : 'auto'} placeholder={isEdit ? '' : 'auto'}
maxLength={30} maxLength={30}
className="font-mono text-sm" className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'}
/> />
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label> <label htmlFor="pbi-title" className="text-sm font-medium">Titel</label>
@ -124,7 +129,9 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
placeholder="PBI-titel…" placeholder="PBI-titel…"
required required
maxLength={200} maxLength={200}
className={fieldError('title') ? 'border-error' : ''}
/> />
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</div> </div>
</div> </div>

View file

@ -98,7 +98,8 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
) )
const fieldError = (field: string) => { const fieldError = (field: string) => {
const err = updateResult?.error const result = isEdit ? updateResult : createResult
const err = result?.error
if (!err || typeof err === 'string') return undefined if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0] return (err as Record<string, string[]>)[field]?.[0]
} }

285
docs/API.md Normal file
View file

@ -0,0 +1,285 @@
# Scrum4Me REST API
REST-API contract voor Claude Code en andere clients.
## Authenticatie
Alle endpoints behalve `GET /api/health` vereisen een Bearer-token:
```
Authorization: Bearer <token>
```
Tokens beheer je via Instellingen → Tokens (`/settings/tokens`). Een token is gekoppeld aan één gebruiker; een demo-account-token kan lezen maar niet schrijven (`403`).
## Status-enums
De API gebruikt **lowercase** statussen. De database gebruikt UPPER_SNAKE; de vertaling gebeurt op de boundary.
| Entiteit | Waarden |
|---|---|
| Task status | `todo`, `in_progress`, `review`, `done` |
| Story status | `open`, `in_sprint`, `done` |
## Foutcodes
| Code | Betekenis |
|---|---|
| `200` | OK |
| `201` | Created |
| `400` | Malformed body (bv. ongeldige JSON) |
| `401` | Token ontbreekt of ongeldig |
| `403` | Token heeft geen toegang (demo-account, geen lid van product) |
| `404` | Resource niet gevonden |
| `422` | Validatiefout — body is wel-gevormd maar niet acceptabel |
| `500` | Onverwachte serverfout |
---
## Endpoints
### `GET /api/health`
Health-probe. Geen authenticatie vereist.
**Query params:** `?db=1` voegt een DB-ping toe.
**Response (200):**
```json
{ "status": "ok", "version": "0.3.x", "time": "2026-04-26T20:00:00Z" }
```
Met `?db=1`:
```json
{ "status": "ok", "version": "0.3.x", "time": "...", "database": "ok" }
```
`database` is `"ok"` of `"down"`. De endpoint zelf retourneert altijd `200`.
```bash
curl https://scrum4me.app/api/health?db=1
```
---
### `GET /api/products`
Lijst van actieve producten waar de tokengebruiker eigenaar of lid van is.
**Response (200):**
```json
[
{
"id": "cmofu...",
"code": "SCRUM4ME",
"name": "Scrum4Me",
"description": "...",
"repo_url": "https://github.com/...",
"definition_of_done": "..."
}
]
```
```bash
curl -H "Authorization: Bearer $TOKEN" https://scrum4me.app/api/products
```
---
### `GET /api/products/:id/claude-context`
Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open todos van de tokengebruiker — in één call.
**Response (200):**
```json
{
"product": { "id", "code", "name", "description", "repo_url", "definition_of_done" },
"active_sprint": { "id": "...", "sprint_goal": "...", "status": "ACTIVE" } | null,
"next_story": {
"id", "code", "title", "description", "acceptance_criteria",
"priority", "status",
"tasks": [
{ "id", "code", "title", "description", "implementation_plan",
"priority", "sort_order", "status" }
]
} | null,
"open_todos": [
{ "id", "title", "description", "created_at" }
]
}
```
`open_todos` is gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen.
```bash
curl -H "Authorization: Bearer $TOKEN" \
https://scrum4me.app/api/products/$PRODUCT_ID/claude-context
```
---
### `GET /api/products/:id/next-story`
Hoogst geprioriteerde open story in de actieve sprint.
**Response (200):**
```json
{
"id": "...",
"code": "ST-356",
"title": "Solo Kanban-bord met DnD en Zustand",
"description": "...",
"acceptance_criteria": "...",
"status": "in_sprint",
"tasks": [
{
"id": "...",
"code": "ST-356.1",
"title": "Store stores/solo-store.ts",
"description": "...",
"implementation_plan": null,
"priority": 2,
"sort_order": 1,
"status": "todo"
}
]
}
```
**Foutcodes:** `404` als geen actieve sprint of geen open stories.
---
### `GET /api/sprints/:id/tasks`
Lijst taken van de sprint, geordend op `(story.sort_order, task.priority, task.sort_order)`.
**Query params:** `?limit=N` (default 10, max 50)
**Response (200):**
```json
[
{
"id": "...",
"code": "ST-356.1",
"title": "...",
"description": "...",
"implementation_plan": null,
"story_id": "...",
"story_code": "ST-356",
"priority": 2,
"sort_order": 1,
"status": "todo"
}
]
```
---
### `PATCH /api/stories/:id/tasks/reorder`
Volgorde van taken binnen een story aanpassen.
**Body:**
```json
{ "task_ids": ["task-id-a", "task-id-b", "task-id-c"] }
```
Alle IDs moeten bij de story horen. **Foutcodes:** `422` bij Zod-fouten of als een task_id niet tot de story behoort.
---
### `PATCH /api/tasks/:id`
Status of implementation_plan bijwerken. Minstens één van beide is verplicht.
Toegestane status-waarden zijn `todo`, `in_progress` en `done`. `review`
wordt door deze endpoint geweigerd zolang de sprint-UI die state niet
rendert — gebruik de Kanban-board voor REVIEW-overgangen.
**Body:**
```json
{ "status": "in_progress", "implementation_plan": "..." }
```
**Response (200):**
```json
{
"id": "...",
"status": "in_progress",
"implementation_plan": "..."
}
```
**Foutcodes:** `422` bij ongeldige body of onbekende status. `403` bij demo-token.
---
### `POST /api/stories/:id/log`
Activiteit vastleggen op een story.
**Body — IMPLEMENTATION_PLAN:**
```json
{
"type": "IMPLEMENTATION_PLAN",
"content": "Plan: ...",
"metadata": { "branch": "feat/x" }
}
```
**Body — TEST_RESULT:**
```json
{
"type": "TEST_RESULT",
"content": "Alle tests groen",
"status": "PASSED",
"metadata": { "ci_run": "..." }
}
```
**Body — COMMIT:**
```json
{
"type": "COMMIT",
"content": "Werk afgerond",
"commit_hash": "abc123",
"commit_message": "feat(ST-XXX): ...",
"metadata": { "branch": "feat/x" }
}
```
`metadata` is optioneel, vrij JSON-object. **Response (201):**
```json
{ "id": "...", "created_at": "..." }
```
---
### `POST /api/todos`
Nieuwe todo voor de tokengebruiker.
**Body:**
```json
{
"title": "Een ding doen",
"description": "Optionele uitleg, max 2000 tekens",
"product_id": "cmof..."
}
```
**Response (201):**
```json
{ "id": "...", "title": "...", "description": "...", "created_at": "..." }
```
---
## Voorbeeldworkflow voor Claude Code
1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn.
2. **Context:** `GET /api/products/$ID/claude-context` — haal product, sprint, volgende story en todos op in één call.
3. **Plan vastleggen:** `POST /api/stories/$STORY_ID/log` met `type: IMPLEMENTATION_PLAN`.
4. **Per task:** `PATCH /api/tasks/$TASK_ID` met `status: "in_progress"`, daarna met `status: "done"` plus eventueel `implementation_plan`.
5. **Test:** `POST /api/stories/$STORY_ID/log` met `type: TEST_RESULT` en `status: PASSED|FAILED`.
6. **Commit:** `POST /api/stories/$STORY_ID/log` met `type: COMMIT`, `commit_hash`, `commit_message`, optioneel `metadata: { branch }`.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Before After
Before After

View file

@ -382,6 +382,38 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
- Gecombineerde lijst op `/settings` van eigen producten (badge "Eigenaar") en team-lidmaatschappen (badge "Developer" + eigenaarsnaam); klikbaar naar product; "Verlaten"-knop met bevestiging voor lidmaatschappen; lege staat met CTA - Gecombineerde lijst op `/settings` van eigen producten (badge "Eigenaar") en team-lidmaatschappen (badge "Developer" + eigenaarsnaam); klikbaar naar product; "Verlaten"-knop met bevestiging voor lidmaatschappen; lege staat met CTA
- Done when: eigenaar-producten en team-producten zichtbaar in één lijst; verlaten werkt en verwijdert rij - Done when: eigenaar-producten en team-producten zichtbaar in één lijst; verlaten werkt en verwijdert rij
- [x] **ST-511** Entity codes voor Product, PBI en Story (buiten originele backlog toegevoegd)
- **Schema:** `code String? @db.VarChar(30)` op `Product`, `Pbi` en `Story`; unique per parent (`user_id` voor Product, `product_id` voor Pbi/Story); `Task` heeft geen DB-veld — code wordt afgeleid als `${story.code}.${index_in_story}`
- **Auto-generatie:** `lib/code-server.ts` met `generateNextStoryCode` (`ST-001`, `ST-002`, … 3-cijferig per product) en `generateNextPbiCode` (`PBI-1`, `PBI-2`, … per product); `createWithCodeRetry`-helper vangt P2002 op het code-veld op en probeert max 3× opnieuw zodat gelijktijdige inserts niet crashen
- **Validatie:** Zod max 30 tekens, regex `^[A-Za-z0-9._-]+$`; handmatige override mag elk format dat aan de basis-regex voldoet (geen format-enforcement op `ST-NNN`)
- **Forms:** code-input op Product/Pbi/Story dialogen; auto-default zichtbaar als placeholder `auto`; field-level error-rendering onder code-input voor zowel create- als edit-mode (uniciteits-conflict, ongeldig format)
- **Display:** `CodeBadge` (`components/shared/code-badge.tsx`) consistent op dashboard product-list, PBI-list, story-blocks (Product Backlog), sprint board (alle drie panelen incl. PBI-headers), solo-bord task-cards, task-detail-dialoog, sprint-afronden-dialoog en de story-dialoog title; task-card toont derived `${story.code}.${index}`-badge rechtsboven uitgelijnd
- **Seed:** parser strip `ST-XXX:`-prefix uit titles, vult `code` apart; product `Scrum4Me` krijgt `code: 'SCRUM4ME'`, milestones krijgen `M0`/`M3.5`/etc., stories krijgen `ST-001…ST-612`
- Done when: auto-toegekende codes per product oplopend en uniek; race-conflict wordt opgevangen door retry-helper i.p.v. te crashen; handmatige duplicate code toont inline error onder de input in zowel create- als edit-mode; codes zichtbaar als badge in alle lijsten/cards/dialogen; seed verdeelt codes correct (8 PBI's met `M*`, 84 stories met `ST-NNN`)
- [x] **ST-512** REST API uitbreidingen voor codes, todo-description en task implementation_plan (buiten originele backlog toegevoegd)
- **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done`
- **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe
- **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`)
- **`POST /api/todos`:** accepteer optionele `description` (max 2000 tekens); valideer en sla op; retourneer `description` in response
- Done when: alle vier endpoints retourneren / accepteren de nieuwe velden zoals beschreven; curl-test toont `code` op products, story en tasks; todo aanmaken via API met `description` slaat op
- [x] **ST-513** REST API hardening voor Claude Code (buiten originele backlog toegevoegd)
- **Health:** nieuwe `GET /api/health` zonder auth; retourneert `{ status, version, time }`; optioneel `?db=1` voor DB-ping (`{ database: 'ok' | 'down' }`)
- **Claude-context:** nieuwe `GET /api/products/:id/claude-context` (auth) die in één call `product`, `active_sprint`, `next_story` (met tasks), en `open_todos` van de gebruiker terugbrengt — voorkomt round-trips
- **Status-case op API-boundary:** nieuwe `lib/task-status.ts` mapper; API exposeert lowercase (`todo`/`in_progress`/`review`/`done` voor tasks; `open`/`in_sprint`/`done` voor stories); DB blijft UPPER_SNAKE; UI ongewijzigd
- **`PATCH /api/tasks/:id`:** accepteert lowercase `status` via mapper; retourneert lowercase
- **Story-log metadata:** nieuwe optionele `metadata Json?` kolom op `StoryLog`; `POST /api/stories/:id/log` accepteert per type een optioneel `metadata`-veld (bv. `{ branch: 'feat/x' }`); bestaande velden ongewijzigd → backwards-compatible
- **Foutcodes:** Zod-validatie geeft `422` (was `400`); `400` blijft voor malformed body; `401`/`403`/`404`/`500` ongewijzigd
- **API-documentatie:** nieuwe `docs/API.md` met endpoints, request/response, foutcodes, status-enums en curl-voorbeelden; `CLAUDE.md` verwijst ernaar
- Done when: `curl /api/health` werkt zonder auth; `curl /api/products/:id/claude-context` retourneert bundled JSON; PATCH/PUT routes accepteren lowercase status en geven 422 bij ongeldige body; story-log POST bewaart `metadata`; `docs/API.md` is gepubliceerd
- **`GET /api/products`:** voeg `code` toe (naast `id`, `name`, `repo_url`); optioneel `description` en `definition_of_done`
- **`GET /api/products/:id/next-story`:** voeg `code` toe op story; voeg per task `code` (derived `${story.code}.${index_in_story}`) en `implementation_plan` toe
- **`GET /api/sprints/:id/tasks`:** voeg `description`, `implementation_plan` en `story_code` toe per task; voeg een derived `code`-veld per task toe (`${story.code}.${index_in_story}`)
- **`POST /api/todos`:** accepteer optionele `description` (max 2000 tekens); valideer en sla op; retourneer `description` in response
- **Backwards-compat:** alle wijzigingen zijn additief — bestaande clients negeren onbekende keys; nieuwe input-velden zijn optioneel
- Done when: alle vier endpoints retourneren / accepteren de nieuwe velden zoals beschreven; curl-test toont `code` op products, story en tasks; todo aanmaken via API met `description` slaat op
--- ---
### M6: Polish & Launch-ready ### M6: Polish & Launch-ready

View file

@ -1,5 +1,43 @@
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
const MAX_AUTO_CODE_ATTEMPTS = 3
function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
if (!target) return false
if (Array.isArray(target)) return target.includes('code')
return target.includes('code')
}
/**
* Generate an auto code, then run the create. If the insert collides with an
* existing code (P2002 on the code column), regenerate and retry up to a small
* number of attempts. Protects against the SELECT-MAX INSERT race when two
* concurrent requests pick the same next number.
*/
export async function createWithCodeRetry<T>(
generate: () => Promise<string>,
create: (code: string) => Promise<T>,
): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt < MAX_AUTO_CODE_ATTEMPTS; attempt++) {
const code = await generate()
try {
return await create(code)
} catch (e) {
if (isCodeUniqueConflict(e)) {
lastError = e
continue
}
throw e
}
}
throw lastError ?? new Error('Kon geen unieke code genereren')
}
const STORY_AUTO_RE = /^ST-(\d+)$/ const STORY_AUTO_RE = /^ST-(\d+)$/
const PBI_AUTO_RE = /^PBI-(\d+)$/ const PBI_AUTO_RE = /^PBI-(\d+)$/

52
lib/task-status.ts Normal file
View file

@ -0,0 +1,52 @@
// Bidirectionele case-mappers voor de REST API-boundary.
// DB houdt UPPER_SNAKE; API exposeert lowercase.
import type { TaskStatus, StoryStatus } from '@prisma/client'
const TASK_DB_TO_API = {
TO_DO: 'todo',
IN_PROGRESS: 'in_progress',
REVIEW: 'review',
DONE: 'done',
} as const satisfies Record<TaskStatus, string>
const TASK_API_TO_DB: Record<string, TaskStatus> = {
todo: 'TO_DO',
in_progress: 'IN_PROGRESS',
review: 'REVIEW',
done: 'DONE',
}
const STORY_DB_TO_API = {
OPEN: 'open',
IN_SPRINT: 'in_sprint',
DONE: 'done',
} as const satisfies Record<StoryStatus, string>
const STORY_API_TO_DB: Record<string, StoryStatus> = {
open: 'OPEN',
in_sprint: 'IN_SPRINT',
done: 'DONE',
}
export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus]
export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus]
export function taskStatusToApi(s: TaskStatus): TaskStatusApi {
return TASK_DB_TO_API[s]
}
export function taskStatusFromApi(s: string): TaskStatus | null {
return TASK_API_TO_DB[s.toLowerCase()] ?? null
}
export function storyStatusToApi(s: StoryStatus): StoryStatusApi {
return STORY_DB_TO_API[s]
}
export function storyStatusFromApi(s: string): StoryStatus | null {
return STORY_API_TO_DB[s.toLowerCase()] ?? null
}
export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API)
export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API)

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "story_logs" ADD COLUMN "metadata" JSONB;

View file

@ -171,6 +171,7 @@ model StoryLog {
status TestStatus? status TestStatus?
commit_hash String? commit_hash String?
commit_message String? commit_message String?
metadata Json?
created_at DateTime @default(now()) created_at DateTime @default(now())
@@index([story_id, created_at]) @@index([story_id, created_at])