Todo description, entity codes, REST API extensions and Claude Code hardening (ST-509/511/512/513) (#2)
* docs(ST-511): add backlog entry for entity codes feature Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ST-511): add createWithCodeRetry helper to handle P2002 race on auto codes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ST-511): retry on auto-code unique conflict in story and pbi create Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ST-511): surface field errors for code and title in PBI dialog Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ST-511): read create-state errors in Story dialog fieldError Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-512): add backlog entry for REST API code/description/implementation_plan extensions; mark ST-511 done Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-512): extend REST API with code, description and implementation_plan - GET /api/products returns code, description and definition_of_done - GET /api/products/:id/next-story returns story.code and per-task code + implementation_plan - GET /api/sprints/:id/tasks returns description, implementation_plan, story_code and derived per-task code - POST /api/todos accepts and returns optional description (max 2000) All changes are additive — existing clients ignore unknown keys. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-512): mark ST-512 as done Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-513): add backlog entry for API hardening for Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-513): add task and story status mappers for API boundary Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-513): expose lowercase status on API and accept lowercase in PATCH /api/tasks Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-513): add metadata JSONB column to StoryLog Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-513): accept optional metadata in story log and switch validation errors to 422 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-513): add GET /api/health endpoint with optional db ping Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ST-513): add GET /api/products/:id/claude-context bundled endpoint Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-513): add docs/API.md and link from CLAUDE.md specs table Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(ST-513): mark ST-513 as done Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ST-513): split 400 (malformed JSON) from 422 (validation), reject 'review' Codex review on PR #2: - P2.1: routes treated JSON parse failures as 422 instead of 400, breaking the contract in docs/API.md. Replace `request.json().catch(() => null)` with try/catch in 4 routes (tasks, reorder, todos, story-log) so a malformed body returns 400 and only well-formed-but-invalid bodies return 422. - P2.2: PATCH /api/tasks/:id accepted `status: "review"`, but the sprint task list UI does not render REVIEW (no label/color, the cycle helper falls back to TO_DO). Reject `review` at the API until the sprint UI is extended; document the subset in docs/API.md. Tests in __tests__/api updated for the new contract (29 assertions: zod-failures now expect 422, status payloads use lowercase API values, sprint-tasks mocks include the new story relation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a8adac127f
commit
43a4294424
29 changed files with 809 additions and 116 deletions
|
|
@ -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 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
24
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import packageJson from '@/package.json'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const checkDb = url.searchParams.get('db') === '1'
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
status: 'ok',
|
||||||
|
version: packageJson.version,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkDb) {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
body.database = 'ok'
|
||||||
|
} catch {
|
||||||
|
body.database = 'down'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(body)
|
||||||
|
}
|
||||||
94
app/api/products/[id]/claude-context/route.ts
Normal file
94
app/api/products/[id]/claude-context/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id, ...productAccessFilter(auth.userId) },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
repo_url: true,
|
||||||
|
definition_of_done: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!product) {
|
||||||
|
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [activeSprint, openTodos] = await Promise.all([
|
||||||
|
prisma.sprint.findFirst({
|
||||||
|
where: { product_id: id, status: 'ACTIVE' },
|
||||||
|
select: { id: true, sprint_goal: true, status: true },
|
||||||
|
}),
|
||||||
|
prisma.todo.findMany({
|
||||||
|
where: { user_id: auth.userId, product_id: id, done: false, archived: false },
|
||||||
|
select: { id: true, title: true, description: true, created_at: true },
|
||||||
|
orderBy: { created_at: 'asc' },
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
let nextStoryPayload: unknown = null
|
||||||
|
if (activeSprint) {
|
||||||
|
const story = await prisma.story.findFirst({
|
||||||
|
where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' },
|
||||||
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
|
include: {
|
||||||
|
tasks: {
|
||||||
|
orderBy: { sort_order: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
implementation_plan: true,
|
||||||
|
priority: true,
|
||||||
|
sort_order: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (story) {
|
||||||
|
nextStoryPayload = {
|
||||||
|
id: story.id,
|
||||||
|
code: story.code,
|
||||||
|
title: story.title,
|
||||||
|
description: story.description,
|
||||||
|
acceptance_criteria: story.acceptance_criteria,
|
||||||
|
priority: story.priority,
|
||||||
|
status: storyStatusToApi(story.status),
|
||||||
|
tasks: story.tasks.map((t, idx) => ({
|
||||||
|
id: t.id,
|
||||||
|
code: story.code ? `${story.code}.${idx + 1}` : null,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
implementation_plan: t.implementation_plan,
|
||||||
|
priority: t.priority,
|
||||||
|
sort_order: t.sort_order,
|
||||||
|
status: taskStatusToApi(t.status),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
product,
|
||||||
|
active_sprint: activeSprint,
|
||||||
|
next_story: nextStoryPayload,
|
||||||
|
open_todos: openTodos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
import { 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
285
docs/API.md
Normal 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 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
52
lib/task-status.ts
Normal 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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "story_logs" ADD COLUMN "metadata" JSONB;
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue