From 43a429442491c29148c921fa2943960f70dd9fb6 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sun, 26 Apr 2026 23:40:54 +0200 Subject: [PATCH] Todo description, entity codes, REST API extensions and Claude Code hardening (ST-509/511/512/513) (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(ST-511): add backlog entry for entity codes feature Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ST-511): add createWithCodeRetry helper to handle P2002 race on auto codes Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ST-511): retry on auto-code unique conflict in story and pbi create Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ST-511): surface field errors for code and title in PBI dialog Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ST-511): read create-state errors in Story dialog fieldError Co-Authored-By: Claude Opus 4.7 (1M context) * 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) * 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) * docs(ST-512): mark ST-512 as done Co-Authored-By: Claude Opus 4.7 (1M context) * docs(ST-513): add backlog entry for API hardening for Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-513): add task and story status mappers for API boundary Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-513): expose lowercase status on API and accept lowercase in PATCH /api/tasks Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-513): add metadata JSONB column to StoryLog Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-513): accept optional metadata in story log and switch validation errors to 422 Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-513): add GET /api/health endpoint with optional db ping Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-513): add GET /api/products/:id/claude-context bundled endpoint Co-Authored-By: Claude Opus 4.7 (1M context) * docs(ST-513): add docs/API.md and link from CLAUDE.md specs table Co-Authored-By: Claude Opus 4.7 (1M context) * docs(ST-513): mark ST-513 as done Co-Authored-By: Claude Opus 4.7 (1M context) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + __tests__/api/next-story.test.ts | 2 +- __tests__/api/reorder.test.ts | 16 +- __tests__/api/security.test.ts | 2 +- __tests__/api/sprint-tasks.test.ts | 17 +- __tests__/api/story-log.test.ts | 32 +- __tests__/api/tasks.test.ts | 44 ++- __tests__/api/todos.test.ts | 16 +- actions/pbis.ts | 45 +-- actions/stories.ts | 51 ++-- app/api/health/route.ts | 24 ++ app/api/products/[id]/claude-context/route.ts | 94 ++++++ app/api/products/[id]/next-story/route.ts | 28 +- app/api/products/route.ts | 2 +- app/api/profile/avatar/route.ts | 4 +- app/api/sprints/[id]/tasks/route.ts | 37 ++- app/api/stories/[id]/log/route.ts | 18 +- app/api/stories/[id]/tasks/reorder/route.ts | 11 +- app/api/tasks/[id]/route.ts | 33 +- app/api/todos/route.ts | 18 +- components/backlog/pbi-dialog.tsx | 15 +- components/backlog/story-dialog.tsx | 3 +- docs/API.md | 285 ++++++++++++++++++ docs/erd.svg | 2 +- docs/scrum4me-backlog.md | 32 ++ lib/code-server.ts | 38 +++ lib/task-status.ts | 52 ++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 29 files changed, 809 insertions(+), 116 deletions(-) create mode 100644 app/api/health/route.ts create mode 100644 app/api/products/[id]/claude-context/route.ts create mode 100644 docs/API.md create mode 100644 lib/task-status.ts create mode 100644 prisma/migrations/20260426214905_add_story_log_metadata/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index 3cd4d1b..d9e4572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-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/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/agent-instruction-audit.md` | Waarom de agent-instructies zijn aangescherpt; checklist voor toekomstige wijzigingen | diff --git a/__tests__/api/next-story.test.ts b/__tests__/api/next-story.test.ts index 6f24444..cc5a86d 100644 --- a/__tests__/api/next-story.test.ts +++ b/__tests__/api/next-story.test.ts @@ -92,7 +92,7 @@ describe('GET /api/products/:id/next-story', () => { acceptance_criteria: '- Gebruikersnaam verplicht', }) 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 () => { diff --git a/__tests__/api/reorder.test.ts b/__tests__/api/reorder.test.ts index 7d37fb8..cff62ae 100644 --- a/__tests__/api/reorder.test.ts +++ b/__tests__/api/reorder.test.ts @@ -55,32 +55,32 @@ describe('PATCH /api/stories/:id/tasks/reorder', () => { }) // 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: [] })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() }) // 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) 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({})) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // 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'])) const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] })) const data = await res.json() - expect(res.status).toBe(400) + expect(res.status).toBe(422) expect(data.error).toContain('task-from-other-story') }) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 54d1e4b..3df9d88 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -389,7 +389,7 @@ describe('PATCH /api/tasks/:id', () => { mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) 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') ) expect(res.status).toBe(200) diff --git a/__tests__/api/sprint-tasks.test.ts b/__tests__/api/sprint-tasks.test.ts index 591a159..c496e0d 100644 --- a/__tests__/api/sprint-tasks.test.ts +++ b/__tests__/api/sprint-tasks.test.ts @@ -28,7 +28,20 @@ const mockAuth = authenticateApiRequest as ReturnType const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } 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 }> }] { @@ -124,7 +137,7 @@ describe('GET /api/sprints/:id/tasks', () => { story_id: 'story-1', priority: 1, sort_order: 1, - status: 'TO_DO', + status: 'todo', }) }) }) diff --git a/__tests__/api/story-log.test.ts b/__tests__/api/story-log.test.ts index 5dec0b1..2ba3025 100644 --- a/__tests__/api/story-log.test.ts +++ b/__tests__/api/story-log.test.ts @@ -48,27 +48,27 @@ describe('POST /api/stories/:id/log', () => { }) // 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) describe('type: IMPLEMENTATION_PLAN', () => { // 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' })) - 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: '' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // TC-L-09 @@ -95,17 +95,17 @@ describe('POST /api/stories/:id/log', () => { describe('type: TEST_RESULT', () => { // 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // TC-L-11 - it('returns 400 for invalid status value', async () => { + it('returns 422 for invalid status value', async () => { const res = await postStoryLog( ...makeRequest({ type: 'TEST_RESULT', content: 'Tests done', status: 'UNKNOWN' }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // TC-L-12 @@ -142,19 +142,19 @@ describe('POST /api/stories/:id/log', () => { describe('type: COMMIT', () => { // 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( ...makeRequest({ type: 'COMMIT', content: 'feat: done', commit_message: 'feat: ST-001' }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // 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( ...makeRequest({ type: 'COMMIT', content: 'feat: done', commit_hash: 'abc1234' }) ) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // TC-L-16 diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index b5eb05f..b51f55b 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -59,31 +59,31 @@ describe('PATCH /api/tasks/:id', () => { }) // 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // 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({})) - 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // TC-T-08 it('updates status only and returns 200 with updated task', async () => { 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() 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.objectContaining({ data: { status: 'IN_PROGRESS' }, @@ -113,11 +113,11 @@ describe('PATCH /api/tasks/:id', () => { const plan = 'Full plan here.' 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() 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.objectContaining({ data: { status: 'DONE', implementation_plan: plan }, @@ -127,20 +127,32 @@ describe('PATCH /api/tasks/:id', () => { // TC-T-11 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 }) mockPrisma.task.findFirst.mockResolvedValue(makeTask({ userId: 'user-2', membersLength: 1 })) 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) }) - it('all three valid status values are accepted', async () => { - for (const status of ['TO_DO', 'IN_PROGRESS', 'DONE'] as const) { - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status, implementation_plan: null }) - const res = await patchTask(...makeRequest({ status })) + it('the three patchable status values are accepted (review is rejected)', async () => { + for (const apiStatus of ['todo', 'in_progress', 'done'] as const) { + const dbStatus = { todo: 'TO_DO', in_progress: 'IN_PROGRESS', done: 'DONE' }[apiStatus] + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: dbStatus, implementation_plan: null }) + const res = await patchTask(...makeRequest({ status: apiStatus })) 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) }) }) diff --git a/__tests__/api/todos.test.ts b/__tests__/api/todos.test.ts index 1ce34b9..abded32 100644 --- a/__tests__/api/todos.test.ts +++ b/__tests__/api/todos.test.ts @@ -45,26 +45,26 @@ describe('POST /api/todos', () => { }) // 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' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // 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' })) - 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)) 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: '' })) - expect(res.status).toBe(400) + expect(res.status).toBe(422) }) // TC-TD-07 diff --git a/actions/pbis.ts b/actions/pbis.ts index ec09e07..b0bce8c 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -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(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 } diff --git a/actions/stories.ts b/actions/stories.ts index 32f1751..cb18a3d 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -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(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 } diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..2c7e2a7 --- /dev/null +++ b/app/api/health/route.ts @@ -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 = { + 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) +} diff --git a/app/api/products/[id]/claude-context/route.ts b/app/api/products/[id]/claude-context/route.ts new file mode 100644 index 0000000..5387a0f --- /dev/null +++ b/app/api/products/[id]/claude-context/route.ts @@ -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, + }) +} diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index 2747f4f..cbd6944 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -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, }) } diff --git a/app/api/products/route.ts b/app/api/products/route.ts index 89c7c9e..862d7e7 100644 --- a/app/api/products/route.ts +++ b/app/api/products/route.ts @@ -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) diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index 16e485f..3bc4907 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -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()) diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts index e2f6d12..6d1e2d3 100644 --- a/app/api/sprints/[id]/tasks/route.ts +++ b/app/api/sprints/[id]/tasks/route.ts @@ -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) } diff --git a/app/api/stories/[id]/log/route.ts b/app/api/stories/[id]/log/route.ts index 8e1daa3..42eb60a 100644 --- a/app/api/stories/[id]/log/route.ts +++ b/app/api/stories/[id]/log/route.ts @@ -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, }, }) diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts index 4c38995..53aeab5 100644 --- a/app/api/stories/[id]/tasks/reorder/route.ts +++ b/app/api/stories/[id]/tasks/reorder/route.ts @@ -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( diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 250aeba..c183ed2 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -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 | 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, }) } diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts index 3836cdc..6a682e5 100644 --- a/app/api/todos/route.ts +++ b/app/api/todos/route.ts @@ -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 }, + ) } diff --git a/components/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index 8174f27..b93de29 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -79,9 +79,13 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { undefined ) - const error = isEdit - ? (typeof updateState?.error === 'string' ? updateState.error : null) - : (typeof createState?.error === 'string' ? createState.error : null) + const activeState = isEdit ? updateState : createState + const error = typeof activeState?.error === 'string' ? activeState.error : null + const fieldError = (field: string) => { + const err = activeState?.error + if (!err || typeof err === 'string') return undefined + return (err as Record)[field]?.[0] + } const titleRef = useRef(null) useEffect(() => { @@ -111,8 +115,9 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { defaultValue={pbi?.code ?? ''} placeholder={isEdit ? '' : 'auto'} maxLength={30} - className="font-mono text-sm" + className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'} /> + {fieldError('code') &&

{fieldError('code')}

}
@@ -124,7 +129,9 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { placeholder="PBI-titel…" required maxLength={200} + className={fieldError('title') ? 'border-error' : ''} /> + {fieldError('title') &&

{fieldError('title')}

}
diff --git a/components/backlog/story-dialog.tsx b/components/backlog/story-dialog.tsx index 068d47f..c9048ec 100644 --- a/components/backlog/story-dialog.tsx +++ b/components/backlog/story-dialog.tsx @@ -98,7 +98,8 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps ) const fieldError = (field: string) => { - const err = updateResult?.error + const result = isEdit ? updateResult : createResult + const err = result?.error if (!err || typeof err === 'string') return undefined return (err as Record)[field]?.[0] } diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..ba9a00e --- /dev/null +++ b/docs/API.md @@ -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 +``` + +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 }`. diff --git a/docs/erd.svg b/docs/erd.svg index 5ee4b12..ac0fd15 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

user

enum:role

user

user

product

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

product

user

user

product

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

\ No newline at end of file +

user

enum:role

user

user

product

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

product

user

user

product

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

\ No newline at end of file diff --git a/docs/scrum4me-backlog.md b/docs/scrum4me-backlog.md index 4ef4aa7..f006f03 100644 --- a/docs/scrum4me-backlog.md +++ b/docs/scrum4me-backlog.md @@ -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 - 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 diff --git a/lib/code-server.ts b/lib/code-server.ts index f78f4fa..5c09fcb 100644 --- a/lib/code-server.ts +++ b/lib/code-server.ts @@ -1,5 +1,43 @@ +import { Prisma } from '@prisma/client' 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( + generate: () => Promise, + create: (code: string) => Promise, +): Promise { + 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 PBI_AUTO_RE = /^PBI-(\d+)$/ diff --git a/lib/task-status.ts b/lib/task-status.ts new file mode 100644 index 0000000..523b61b --- /dev/null +++ b/lib/task-status.ts @@ -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 + +const TASK_API_TO_DB: Record = { + 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 + +const STORY_API_TO_DB: Record = { + 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) diff --git a/prisma/migrations/20260426214905_add_story_log_metadata/migration.sql b/prisma/migrations/20260426214905_add_story_log_metadata/migration.sql new file mode 100644 index 0000000..0f4d163 --- /dev/null +++ b/prisma/migrations/20260426214905_add_story_log_metadata/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "story_logs" ADD COLUMN "metadata" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6acf5a1..e9d6854 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -171,6 +171,7 @@ model StoryLog { status TestStatus? commit_hash String? commit_message String? + metadata Json? created_at DateTime @default(now()) @@index([story_id, created_at])