Merge pull request #91 from madhura68/feat/m12-ideas
M12 — Idea entity + Grill/Plan jobs
This commit is contained in:
commit
2893573004
51 changed files with 5623 additions and 141 deletions
546
__tests__/actions/ideas-crud.test.ts
Normal file
546
__tests__/actions/ideas-crud.test.ts
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockSession } = vi.hoisted(() => ({
|
||||
mockSession: { userId: 'user-1', isDemo: false },
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn().mockImplementation(async () => mockSession),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' },
|
||||
}))
|
||||
vi.mock('@/lib/idea-code-server', () => ({
|
||||
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
idea: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
ideaLog: { create: vi.fn() },
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeWorker: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
createIdeaAction,
|
||||
updateIdeaAction,
|
||||
archiveIdeaAction,
|
||||
deleteIdeaAction,
|
||||
updateGrillMdAction,
|
||||
updatePlanMdAction,
|
||||
downloadIdeaMdAction,
|
||||
startGrillJobAction,
|
||||
startMakePlanJobAction,
|
||||
cancelIdeaJobAction,
|
||||
materializeIdeaPlanAction,
|
||||
relinkIdeaPlanAction,
|
||||
} from '@/actions/ideas'
|
||||
|
||||
type MockIdea = {
|
||||
idea: { create: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> }
|
||||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
||||
claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
claudeWorker: { count: ReturnType<typeof vi.fn> }
|
||||
pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
||||
story: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
||||
task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const m = prisma as unknown as MockIdea
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSession.userId = 'user-1'
|
||||
mockSession.isDemo = false
|
||||
// Default: $transaction passes its callback through with our mocked prisma
|
||||
m.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (typeof arg === 'function') {
|
||||
return (arg as (tx: unknown) => unknown)(m)
|
||||
}
|
||||
return arg
|
||||
})
|
||||
})
|
||||
|
||||
describe('createIdeaAction', () => {
|
||||
it('happy path: creates DRAFT idea with auto-generated code', async () => {
|
||||
m.idea.create.mockResolvedValueOnce({ id: 'idea-1', code: 'IDEA-001' })
|
||||
|
||||
const r = await createIdeaAction({ title: 'Plant-watering reminder' })
|
||||
expect(r).toEqual({ success: true, data: { id: 'idea-1', code: 'IDEA-001' } })
|
||||
expect(m.idea.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
user_id: 'user-1',
|
||||
code: 'IDEA-001',
|
||||
title: 'Plant-watering reminder',
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unauthenticated', async () => {
|
||||
mockSession.userId = ''
|
||||
const r = await createIdeaAction({ title: 'x' })
|
||||
expect(r).toMatchObject({ error: expect.stringMatching(/ingelogd/), code: 401 })
|
||||
expect(m.idea.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects demo-user', async () => {
|
||||
mockSession.isDemo = true
|
||||
const r = await createIdeaAction({ title: 'x' })
|
||||
expect(r).toMatchObject({ error: expect.stringMatching(/demo/), code: 403 })
|
||||
expect(m.idea.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid title (zod 422)', async () => {
|
||||
const r = await createIdeaAction({ title: ' ' })
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.idea.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateIdeaAction', () => {
|
||||
it('happy: updates editable idea (DRAFT)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
|
||||
m.idea.update.mockResolvedValueOnce({})
|
||||
|
||||
const r = await updateIdeaAction('idea-1', { title: 'Updated' })
|
||||
expect(r).toEqual({ success: true })
|
||||
expect(m.idea.update).toHaveBeenCalledWith({
|
||||
where: { id: 'idea-1' },
|
||||
data: { title: 'Updated' },
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks update on PLANNED (status-mismatch 422)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
|
||||
const r = await updateIdeaAction('idea-1', { title: 'x' })
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.idea.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks update during GRILLING', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'GRILLING' })
|
||||
const r = await updateIdeaAction('idea-1', { title: 'x' })
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
|
||||
it('returns 404 when idea belongs to another user', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce(null)
|
||||
const r = await updateIdeaAction('idea-1', { title: 'x' })
|
||||
expect(r).toMatchObject({ code: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteIdeaAction', () => {
|
||||
it('happy: deletes idea without pbi', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: null })
|
||||
const r = await deleteIdeaAction('idea-1')
|
||||
expect(r).toEqual({ success: true })
|
||||
expect(m.idea.delete).toHaveBeenCalledWith({ where: { id: 'idea-1' } })
|
||||
})
|
||||
|
||||
it('blocks deletion when PBI is linked', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: 'pbi-1' })
|
||||
const r = await deleteIdeaAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.idea.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('archiveIdeaAction', () => {
|
||||
it('archives owned idea', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1' })
|
||||
const r = await archiveIdeaAction('idea-1')
|
||||
expect(r).toEqual({ success: true })
|
||||
expect(m.idea.update).toHaveBeenCalledWith({
|
||||
where: { id: 'idea-1' },
|
||||
data: { archived: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateGrillMdAction', () => {
|
||||
it('happy: updates grill_md in GRILLED', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' })
|
||||
const r = await updateGrillMdAction('idea-1', '# Updated grill')
|
||||
expect(r).toEqual({ success: true })
|
||||
expect(m.$transaction).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks in DRAFT', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' })
|
||||
const r = await updateGrillMdAction('idea-1', 'x')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePlanMdAction', () => {
|
||||
const VALID_PLAN = `---
|
||||
pbi:
|
||||
title: Test
|
||||
priority: 2
|
||||
stories:
|
||||
- title: S1
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: T1
|
||||
priority: 2
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
|
||||
it('happy: updates plan_md in PLAN_READY with valid yaml', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
|
||||
const r = await updatePlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('rejects invalid yaml (parse-fail 422 with details)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' })
|
||||
const r = await updatePlanMdAction('idea-1', '# no frontmatter')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect((r as { details?: unknown }).details).toBeDefined()
|
||||
})
|
||||
|
||||
it('blocks in PLANNED', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' })
|
||||
const r = await updatePlanMdAction('idea-1', VALID_PLAN)
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('startGrillJobAction', () => {
|
||||
const idea = {
|
||||
id: 'idea-1',
|
||||
status: 'DRAFT',
|
||||
product_id: 'prod-1',
|
||||
product: { id: 'prod-1', repo_url: 'https://github.com/x/y' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
m.idea.findFirst.mockResolvedValue(idea)
|
||||
m.claudeJob.findFirst.mockResolvedValue(null)
|
||||
m.claudeWorker.count.mockResolvedValue(1)
|
||||
m.claudeJob.create.mockResolvedValue({ id: 'job-1' })
|
||||
})
|
||||
|
||||
it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => {
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } })
|
||||
expect(m.$executeRaw).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks demo-user', async () => {
|
||||
mockSession.isDemo = true
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 403 })
|
||||
expect(m.claudeJob.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks when product has no repo_url', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
...idea,
|
||||
product: { id: 'prod-1', repo_url: null },
|
||||
})
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) })
|
||||
})
|
||||
|
||||
it('blocks when no idea is unlinked', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null })
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
|
||||
it('blocks when no worker is active', async () => {
|
||||
m.claudeWorker.count.mockResolvedValueOnce(0)
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) })
|
||||
expect(m.claudeJob.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks when an active job already exists (409)', async () => {
|
||||
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' })
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 409 })
|
||||
})
|
||||
|
||||
it('blocks invalid status (PLANNING)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' })
|
||||
const r = await startGrillJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('startMakePlanJobAction', () => {
|
||||
const idea = {
|
||||
id: 'idea-1',
|
||||
status: 'GRILLED',
|
||||
product_id: 'prod-1',
|
||||
product: { id: 'prod-1', repo_url: 'https://github.com/x/y' },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
m.idea.findFirst.mockResolvedValue(idea)
|
||||
m.claudeJob.findFirst.mockResolvedValue(null)
|
||||
m.claudeWorker.count.mockResolvedValue(1)
|
||||
m.claudeJob.create.mockResolvedValue({ id: 'job-2' })
|
||||
})
|
||||
|
||||
it('happy: GRILLED → PLANNING', async () => {
|
||||
const r = await startMakePlanJobAction('idea-1')
|
||||
expect(r).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('blocks from DRAFT (must grill first)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' })
|
||||
const r = await startMakePlanJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelIdeaJobAction', () => {
|
||||
it('grill cancel without prior grill_md → DRAFT', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'GRILLING',
|
||||
grill_md: null,
|
||||
plan_md: null,
|
||||
})
|
||||
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' })
|
||||
|
||||
const r = await cancelIdeaJobAction('idea-1')
|
||||
expect(r).toEqual({ success: true })
|
||||
// Verify $transaction was called with 3 ops (job-update, idea-update, log)
|
||||
expect(m.$transaction).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('grill re-grill cancel with prior grill_md → GRILLED', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'GRILLING',
|
||||
grill_md: '# old grill',
|
||||
plan_md: null,
|
||||
})
|
||||
m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' })
|
||||
|
||||
const r = await cancelIdeaJobAction('idea-1')
|
||||
expect(r).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('returns 404 when no active job', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'GRILLED',
|
||||
grill_md: null,
|
||||
plan_md: null,
|
||||
})
|
||||
m.claudeJob.findFirst.mockResolvedValueOnce(null)
|
||||
const r = await cancelIdeaJobAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('materializeIdeaPlanAction', () => {
|
||||
const VALID_PLAN = `---
|
||||
pbi:
|
||||
title: New PBI
|
||||
priority: 2
|
||||
stories:
|
||||
- title: Story A
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: Task A1
|
||||
priority: 2
|
||||
implementation_plan: "1. Doe X"
|
||||
- title: Task A2
|
||||
priority: 2
|
||||
- title: Story B
|
||||
priority: 3
|
||||
tasks:
|
||||
- title: Task B1
|
||||
priority: 3
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
|
||||
beforeEach(() => {
|
||||
m.idea.findFirst.mockResolvedValue({
|
||||
id: 'idea-1',
|
||||
status: 'PLAN_READY',
|
||||
product_id: 'prod-1',
|
||||
plan_md: VALID_PLAN,
|
||||
})
|
||||
m.pbi.findMany.mockResolvedValue([])
|
||||
m.story.findMany.mockResolvedValue([])
|
||||
m.task.findMany.mockResolvedValue([])
|
||||
m.pbi.findFirst.mockResolvedValue(null)
|
||||
m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' })
|
||||
m.story.create
|
||||
.mockResolvedValueOnce({ id: 's-A' })
|
||||
.mockResolvedValueOnce({ id: 's-B' })
|
||||
m.task.create
|
||||
.mockResolvedValueOnce({ id: 't-A1' })
|
||||
.mockResolvedValueOnce({ id: 't-A2' })
|
||||
.mockResolvedValueOnce({ id: 't-B1' })
|
||||
})
|
||||
|
||||
it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => {
|
||||
const r = await materializeIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
pbi_id: 'pbi-1',
|
||||
pbi_code: 'PBI-1',
|
||||
story_ids: ['s-A', 's-B'],
|
||||
task_ids: ['t-A1', 't-A2', 't-B1'],
|
||||
},
|
||||
})
|
||||
expect(m.pbi.create).toHaveBeenCalledTimes(1)
|
||||
expect(m.story.create).toHaveBeenCalledTimes(2)
|
||||
expect(m.task.create).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('blocks when not PLAN_READY (e.g. GRILLED)', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'GRILLED',
|
||||
product_id: 'prod-1',
|
||||
plan_md: VALID_PLAN,
|
||||
})
|
||||
const r = await materializeIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.pbi.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 422 with details on parse-fail', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'PLAN_READY',
|
||||
product_id: 'prod-1',
|
||||
plan_md: '# no frontmatter',
|
||||
})
|
||||
const r = await materializeIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect((r as { details?: unknown }).details).toBeDefined()
|
||||
})
|
||||
|
||||
it('blocks demo-user', async () => {
|
||||
mockSession.isDemo = true
|
||||
const r = await materializeIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 403 })
|
||||
})
|
||||
|
||||
it('returns 409 on P2002 race', async () => {
|
||||
m.$transaction.mockImplementationOnce(async () => {
|
||||
throw new Error('Unique constraint failed (P2002)')
|
||||
})
|
||||
const r = await materializeIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('relinkIdeaPlanAction', () => {
|
||||
it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'PLANNED',
|
||||
pbi_id: null,
|
||||
})
|
||||
const r = await relinkIdeaPlanAction('idea-1')
|
||||
expect(r).toEqual({ success: true })
|
||||
expect(m.$transaction).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks when pbi still linked', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'PLANNED',
|
||||
pbi_id: 'pbi-1',
|
||||
})
|
||||
const r = await relinkIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
|
||||
it('blocks when not PLANNED', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
id: 'idea-1',
|
||||
status: 'PLAN_READY',
|
||||
pbi_id: null,
|
||||
})
|
||||
const r = await relinkIdeaPlanAction('idea-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadIdeaMdAction', () => {
|
||||
it('returns grill_md when present', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
code: 'IDEA-001',
|
||||
grill_md: '# Idee\nscope',
|
||||
plan_md: null,
|
||||
})
|
||||
const r = await downloadIdeaMdAction('idea-1', 'grill')
|
||||
expect(r).toMatchObject({
|
||||
success: true,
|
||||
data: { filename: 'IDEA-001-grill.md', markdown: '# Idee\nscope' },
|
||||
})
|
||||
})
|
||||
|
||||
it('404 when md not yet generated', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
code: 'IDEA-001',
|
||||
grill_md: null,
|
||||
plan_md: null,
|
||||
})
|
||||
const r = await downloadIdeaMdAction('idea-1', 'plan')
|
||||
expect(r).toMatchObject({ code: 404 })
|
||||
})
|
||||
|
||||
it('demo MAY download (read-only operation)', async () => {
|
||||
mockSession.isDemo = true
|
||||
m.idea.findFirst.mockResolvedValueOnce({
|
||||
code: 'IDEA-001',
|
||||
grill_md: 'x',
|
||||
plan_md: null,
|
||||
})
|
||||
const r = await downloadIdeaMdAction('idea-1', 'grill')
|
||||
expect(r).toMatchObject({ success: true })
|
||||
})
|
||||
})
|
||||
114
__tests__/actions/todos-promote-idea.test.ts
Normal file
114
__tests__/actions/todos-promote-idea.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const { mockSession } = vi.hoisted(() => ({
|
||||
mockSession: { userId: 'user-1', isDemo: false },
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn().mockImplementation(async () => mockSession),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' },
|
||||
}))
|
||||
vi.mock('@/lib/idea-code-server', () => ({
|
||||
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-005'),
|
||||
}))
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
vi.mock('@/lib/code-server', () => ({
|
||||
generateNextPbiCode: vi.fn(),
|
||||
generateNextStoryCode: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/rate-limit', () => ({
|
||||
enforceUserRateLimit: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
todo: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
idea: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
ideaLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { promoteTodoToIdeaAction } from '@/actions/todos'
|
||||
|
||||
type M = {
|
||||
todo: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
idea: { create: ReturnType<typeof vi.fn> }
|
||||
ideaLog: { create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const m = prisma as unknown as M
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSession.userId = 'user-1'
|
||||
mockSession.isDemo = false
|
||||
m.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (typeof arg === 'function') {
|
||||
return (arg as (tx: unknown) => unknown)(m)
|
||||
}
|
||||
return arg
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteTodoToIdeaAction', () => {
|
||||
it('happy: archives todo, creates DRAFT idea, returns idea_id', async () => {
|
||||
m.todo.findFirst.mockResolvedValueOnce({
|
||||
id: 'todo-1',
|
||||
title: 'My idea',
|
||||
description: 'desc',
|
||||
product_id: null,
|
||||
archived: false,
|
||||
})
|
||||
m.idea.create.mockResolvedValueOnce({ id: 'idea-9', code: 'IDEA-005' })
|
||||
|
||||
const r = await promoteTodoToIdeaAction('todo-1')
|
||||
expect(r).toMatchObject({ success: true, idea_id: 'idea-9', idea_code: 'IDEA-005' })
|
||||
expect(m.todo.update).toHaveBeenCalledWith({
|
||||
where: { id: 'todo-1' },
|
||||
data: { archived: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unauthenticated', async () => {
|
||||
mockSession.userId = ''
|
||||
const r = await promoteTodoToIdeaAction('todo-1')
|
||||
expect(r).toMatchObject({ code: 401 })
|
||||
})
|
||||
|
||||
it('rejects demo-user', async () => {
|
||||
mockSession.isDemo = true
|
||||
const r = await promoteTodoToIdeaAction('todo-1')
|
||||
expect(r).toMatchObject({ code: 403 })
|
||||
})
|
||||
|
||||
it('returns 404 when todo belongs to another user', async () => {
|
||||
m.todo.findFirst.mockResolvedValueOnce(null)
|
||||
const r = await promoteTodoToIdeaAction('todo-1')
|
||||
expect(r).toMatchObject({ code: 404 })
|
||||
})
|
||||
|
||||
it('rejects already-archived todo', async () => {
|
||||
m.todo.findFirst.mockResolvedValueOnce({
|
||||
id: 'todo-1',
|
||||
title: 'x',
|
||||
description: null,
|
||||
product_id: null,
|
||||
archived: true,
|
||||
})
|
||||
const r = await promoteTodoToIdeaAction('todo-1')
|
||||
expect(r).toMatchObject({ code: 422 })
|
||||
expect(m.idea.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
194
__tests__/api/ideas.test.ts
Normal file
194
__tests__/api/ideas.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn() },
|
||||
idea: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
ideaLog: { findMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/api-auth', () => ({
|
||||
authenticateApiRequest: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/idea-code-server', () => ({
|
||||
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route'
|
||||
import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route'
|
||||
|
||||
type M = {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
idea: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
ideaLog: { findMany: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const m = prisma as unknown as M
|
||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
const NOW = new Date('2026-05-04T19:00:00Z')
|
||||
|
||||
const IDEA_ROW = {
|
||||
id: 'idea-1',
|
||||
user_id: 'user-1',
|
||||
code: 'IDEA-001',
|
||||
title: 'Plant-watering reminder',
|
||||
description: null,
|
||||
status: 'DRAFT' as const,
|
||||
product_id: null,
|
||||
product: null,
|
||||
pbi: null,
|
||||
pbi_id: null,
|
||||
archived: false,
|
||||
grill_md: null,
|
||||
plan_md: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
}
|
||||
|
||||
function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request {
|
||||
return new Request(`http://localhost${url}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||
m.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m)
|
||||
return arg
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/ideas', () => {
|
||||
it('returns user ideas (DTO shape)', async () => {
|
||||
m.idea.findMany.mockResolvedValueOnce([IDEA_ROW])
|
||||
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.ideas).toHaveLength(1)
|
||||
expect(body.ideas[0]).toMatchObject({
|
||||
id: 'idea-1',
|
||||
code: 'IDEA-001',
|
||||
status: 'draft',
|
||||
has_grill_md: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unauthenticated', async () => {
|
||||
mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 })
|
||||
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('filters by archived=false param', async () => {
|
||||
m.idea.findMany.mockResolvedValueOnce([])
|
||||
await getIdeas(makeRequest('GET', '/api/ideas?archived=false'))
|
||||
expect(m.idea.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ archived: false, user_id: 'user-1' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/ideas', () => {
|
||||
it('creates idea and returns 201', async () => {
|
||||
m.idea.create.mockResolvedValueOnce(IDEA_ROW)
|
||||
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' }))
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' })
|
||||
})
|
||||
|
||||
it('rejects demo with 403', async () => {
|
||||
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
|
||||
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' }))
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects empty title with 422', async () => {
|
||||
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' }))
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
|
||||
it('rejects malformed JSON with 400', async () => {
|
||||
const req = new Request('http://localhost/api/ideas', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' },
|
||||
body: 'not-json',
|
||||
})
|
||||
const res = await postIdea(req)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when product_id refers to a foreign product', async () => {
|
||||
m.product.findFirst.mockResolvedValueOnce(null)
|
||||
const res = await postIdea(
|
||||
makeRequest('POST', '/api/ideas', {
|
||||
title: 'x',
|
||||
product_id: 'cmohrysyj0000rd17clnjy4tc',
|
||||
}),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/ideas/[id]', () => {
|
||||
it('returns idea + logs', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW)
|
||||
m.ideaLog.findMany.mockResolvedValueOnce([
|
||||
{ id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW },
|
||||
])
|
||||
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
||||
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.idea).toMatchObject({ id: 'idea-1' })
|
||||
expect(body.logs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns 404 (not 403) for foreign user — anti-enumeration', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce(null)
|
||||
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
||||
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/ideas/[id]', () => {
|
||||
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
||||
|
||||
it('updates editable idea', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
|
||||
m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' })
|
||||
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('blocks demo with 403', async () => {
|
||||
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
|
||||
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('blocks update on PLANNED with 422', async () => {
|
||||
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
|
||||
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
|
||||
expect(res.status).toBe(422)
|
||||
})
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({
|
|||
prisma: {
|
||||
product: { findMany: vi.fn() },
|
||||
claudeQuestion: { findMany: vi.fn() },
|
||||
idea: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
|||
21
__tests__/lib/idea-code.test.ts
Normal file
21
__tests__/lib/idea-code.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { formatIdeaCode } from '@/lib/idea-code'
|
||||
|
||||
describe('formatIdeaCode', () => {
|
||||
it('pads to 3 digits', () => {
|
||||
expect(formatIdeaCode(1)).toBe('IDEA-001')
|
||||
expect(formatIdeaCode(42)).toBe('IDEA-042')
|
||||
expect(formatIdeaCode(999)).toBe('IDEA-999')
|
||||
})
|
||||
|
||||
it('does not truncate beyond pad-width', () => {
|
||||
expect(formatIdeaCode(1000)).toBe('IDEA-1000')
|
||||
expect(formatIdeaCode(99999)).toBe('IDEA-99999')
|
||||
})
|
||||
})
|
||||
|
||||
// Integration-style concurrency-test op nextIdeaCode is in
|
||||
// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap).
|
||||
// Hier alleen de pure formatter; de increment-logica leunt op Prisma's
|
||||
// row-lock in $transaction die we per-database vertrouwen.
|
||||
103
__tests__/lib/idea-plan-parser.test.ts
Normal file
103
__tests__/lib/idea-plan-parser.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { parsePlanMd } from '@/lib/idea-plan-parser'
|
||||
|
||||
const VALID = `---
|
||||
pbi:
|
||||
title: Test PBI
|
||||
priority: 2
|
||||
stories:
|
||||
- title: Eerste flow
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: Setup
|
||||
priority: 2
|
||||
implementation_plan: |
|
||||
1. Doe X
|
||||
2. Doe Y
|
||||
---
|
||||
|
||||
# Overwegingen
|
||||
|
||||
Dit is de body, niet geparsed.
|
||||
`
|
||||
|
||||
describe('parsePlanMd', () => {
|
||||
it('parses a valid plan', () => {
|
||||
const r = parsePlanMd(VALID)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.plan.pbi.title).toBe('Test PBI')
|
||||
expect(r.plan.stories).toHaveLength(1)
|
||||
expect(r.plan.stories[0].tasks).toHaveLength(1)
|
||||
expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X')
|
||||
expect(r.body).toContain('# Overwegingen')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects when frontmatter is missing', () => {
|
||||
const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.')
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) {
|
||||
expect(r.errors[0].line).toBe(1)
|
||||
expect(r.errors[0].message).toMatch(/frontmatter/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports yaml syntax error with line info', () => {
|
||||
const broken = `---
|
||||
pbi:
|
||||
title: Test
|
||||
priority: [unclosed
|
||||
stories:
|
||||
- foo
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
const r = parsePlanMd(broken)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) {
|
||||
expect(r.errors[0].message.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports schema-validation error when pbi-section missing', () => {
|
||||
const noPbi = `---
|
||||
stories:
|
||||
- title: x
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: y
|
||||
priority: 2
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
const r = parsePlanMd(noPbi)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) {
|
||||
expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty stories array', () => {
|
||||
const noStories = `---
|
||||
pbi:
|
||||
title: x
|
||||
priority: 2
|
||||
stories: []
|
||||
---
|
||||
|
||||
body
|
||||
`
|
||||
const r = parsePlanMd(noStories)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('handles CRLF line endings', () => {
|
||||
const crlf = VALID.replace(/\n/g, '\r\n')
|
||||
const r = parsePlanMd(crlf)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
131
__tests__/lib/idea-schemas.test.ts
Normal file
131
__tests__/lib/idea-schemas.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
ideaCreateSchema,
|
||||
ideaUpdateSchema,
|
||||
ideaPlanMdFrontmatterSchema,
|
||||
} from '@/lib/schemas/idea'
|
||||
|
||||
describe('ideaCreateSchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const r = ideaCreateSchema.safeParse({ title: 'Plant-watering reminder' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('trims and enforces non-empty title', () => {
|
||||
const r = ideaCreateSchema.safeParse({ title: ' ' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects oversized title and description', () => {
|
||||
expect(ideaCreateSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false)
|
||||
expect(
|
||||
ideaCreateSchema.safeParse({ title: 'ok', description: 'x'.repeat(4001) }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts cuid-like product_id', () => {
|
||||
const r = ideaCreateSchema.safeParse({
|
||||
title: 'Idee',
|
||||
product_id: 'cmohrysyj0000rd17clnjy4tc',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-cuid product_id', () => {
|
||||
const r = ideaCreateSchema.safeParse({ title: 'Idee', product_id: 'not-a-cuid' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ideaUpdateSchema', () => {
|
||||
it('allows empty object (no-op update)', () => {
|
||||
expect(ideaUpdateSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('allows partial title update', () => {
|
||||
expect(ideaUpdateSchema.safeParse({ title: 'Updated' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ideaPlanMdFrontmatterSchema', () => {
|
||||
const validPlan = {
|
||||
pbi: { title: 'Test PBI', priority: 2 },
|
||||
stories: [
|
||||
{
|
||||
title: 'Eerste flow',
|
||||
priority: 2,
|
||||
tasks: [
|
||||
{ title: 'Setup', priority: 2, implementation_plan: '1. Doe X' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('accepts a minimal valid plan', () => {
|
||||
expect(ideaPlanMdFrontmatterSchema.safeParse(validPlan).success).toBe(true)
|
||||
})
|
||||
|
||||
it('requires at least one story', () => {
|
||||
const r = ideaPlanMdFrontmatterSchema.safeParse({ ...validPlan, stories: [] })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires at least one task per story', () => {
|
||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
||||
...validPlan,
|
||||
stories: [{ ...validPlan.stories[0], tasks: [] }],
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates priority bounds 1-4', () => {
|
||||
expect(
|
||||
ideaPlanMdFrontmatterSchema.safeParse({
|
||||
...validPlan,
|
||||
pbi: { ...validPlan.pbi, priority: 5 },
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
ideaPlanMdFrontmatterSchema.safeParse({
|
||||
...validPlan,
|
||||
pbi: { ...validPlan.pbi, priority: 0 },
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts optional verify_required + verify_only', () => {
|
||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
||||
...validPlan,
|
||||
stories: [
|
||||
{
|
||||
...validPlan.stories[0],
|
||||
tasks: [
|
||||
{
|
||||
title: 'Verify-only task',
|
||||
priority: 2,
|
||||
verify_required: 'ALIGNED_OR_PARTIAL',
|
||||
verify_only: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid verify_required enum', () => {
|
||||
const r = ideaPlanMdFrontmatterSchema.safeParse({
|
||||
...validPlan,
|
||||
stories: [
|
||||
{
|
||||
...validPlan.stories[0],
|
||||
tasks: [
|
||||
{ title: 't', priority: 2, verify_required: 'INVALID' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
99
__tests__/lib/idea-status.test.ts
Normal file
99
__tests__/lib/idea-status.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
ideaStatusToApi,
|
||||
ideaStatusFromApi,
|
||||
canTransition,
|
||||
isIdeaEditable,
|
||||
isGrillMdEditable,
|
||||
isPlanMdEditable,
|
||||
IDEA_STATUS_API_VALUES,
|
||||
} from '@/lib/idea-status'
|
||||
|
||||
describe('idea-status mappers', () => {
|
||||
it('round-trips every API value', () => {
|
||||
for (const api of IDEA_STATUS_API_VALUES) {
|
||||
const db = ideaStatusFromApi(api)
|
||||
expect(db).not.toBeNull()
|
||||
expect(ideaStatusToApi(db!)).toBe(api)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(ideaStatusFromApi('NOT_A_STATUS')).toBeNull()
|
||||
})
|
||||
|
||||
it('is case-insensitive on the API side', () => {
|
||||
expect(ideaStatusFromApi('PLAN_READY')).toBe('PLAN_READY')
|
||||
expect(ideaStatusFromApi('Plan_Ready')).toBe('PLAN_READY')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canTransition', () => {
|
||||
it('allows valid forward transitions', () => {
|
||||
expect(canTransition('DRAFT', 'GRILLING')).toBe(true)
|
||||
expect(canTransition('GRILLING', 'GRILLED')).toBe(true)
|
||||
expect(canTransition('GRILLED', 'PLANNING')).toBe(true)
|
||||
expect(canTransition('PLANNING', 'PLAN_READY')).toBe(true)
|
||||
expect(canTransition('PLAN_READY', 'PLANNED')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows re-grill from GRILLED and PLAN_READY-ish states', () => {
|
||||
expect(canTransition('GRILLED', 'GRILLING')).toBe(true)
|
||||
expect(canTransition('PLAN_FAILED', 'PLANNING')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows fail-side transitions', () => {
|
||||
expect(canTransition('GRILLING', 'GRILL_FAILED')).toBe(true)
|
||||
expect(canTransition('PLANNING', 'PLAN_FAILED')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows recovery from failed states', () => {
|
||||
expect(canTransition('GRILL_FAILED', 'GRILLING')).toBe(true)
|
||||
expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true)
|
||||
})
|
||||
|
||||
it('only allows PLANNED → PLAN_READY (relink path)', () => {
|
||||
expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true)
|
||||
expect(canTransition('PLANNED', 'GRILLING')).toBe(false)
|
||||
expect(canTransition('PLANNED', 'DRAFT')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid jumps', () => {
|
||||
expect(canTransition('DRAFT', 'PLANNED')).toBe(false)
|
||||
expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false)
|
||||
expect(canTransition('GRILLING', 'PLANNED')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIdeaEditable', () => {
|
||||
it('allows edit in non-running, non-PLANNED states', () => {
|
||||
expect(isIdeaEditable('DRAFT')).toBe(true)
|
||||
expect(isIdeaEditable('GRILLED')).toBe(true)
|
||||
expect(isIdeaEditable('GRILL_FAILED')).toBe(true)
|
||||
expect(isIdeaEditable('PLAN_FAILED')).toBe(true)
|
||||
expect(isIdeaEditable('PLAN_READY')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks edit while a job is running or after PLANNED', () => {
|
||||
expect(isIdeaEditable('GRILLING')).toBe(false)
|
||||
expect(isIdeaEditable('PLANNING')).toBe(false)
|
||||
expect(isIdeaEditable('PLANNED')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGrillMdEditable / isPlanMdEditable', () => {
|
||||
it('grill_md only editable in GRILLED or PLAN_READY', () => {
|
||||
expect(isGrillMdEditable('GRILLED')).toBe(true)
|
||||
expect(isGrillMdEditable('PLAN_READY')).toBe(true)
|
||||
expect(isGrillMdEditable('DRAFT')).toBe(false)
|
||||
expect(isGrillMdEditable('PLANNED')).toBe(false)
|
||||
})
|
||||
|
||||
it('plan_md only editable in PLAN_READY', () => {
|
||||
expect(isPlanMdEditable('PLAN_READY')).toBe(true)
|
||||
expect(isPlanMdEditable('GRILLED')).toBe(false)
|
||||
expect(isPlanMdEditable('PLAN_FAILED')).toBe(false)
|
||||
expect(isPlanMdEditable('PLANNED')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -30,6 +30,26 @@ beforeEach(() => {
|
|||
})
|
||||
|
||||
describe('proxy demo-guard', () => {
|
||||
it('demo + POST /api/ideas → 403 (M12)', async () => {
|
||||
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
||||
const req = makeRequest('POST', '/api/ideas', true)
|
||||
const res = await proxy(req)
|
||||
expect(res?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('demo + PATCH /api/ideas/abc → 403 (M12)', async () => {
|
||||
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
||||
const req = makeRequest('PATCH', '/api/ideas/abc', true)
|
||||
const res = await proxy(req)
|
||||
expect(res?.status).toBe(403)
|
||||
})
|
||||
|
||||
it('demo + GET /api/ideas → passthrough (M12)', async () => {
|
||||
const req = makeRequest('GET', '/api/ideas', true)
|
||||
const res = await proxy(req)
|
||||
expect(res?.status).not.toBe(403)
|
||||
})
|
||||
|
||||
it('demo + POST /api/todos → 403', async () => {
|
||||
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
||||
const req = makeRequest('POST', '/api/todos', true)
|
||||
|
|
|
|||
145
__tests__/stores/idea-store.test.ts
Normal file
145
__tests__/stores/idea-store.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
|
||||
import { useIdeaStore } from '@/stores/idea-store'
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store between tests — Zustand persists state across tests otherwise.
|
||||
useIdeaStore.setState({
|
||||
jobByIdea: {},
|
||||
ideaStatuses: {},
|
||||
openQuestionsByIdea: {},
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIdeaStore — handleIdeaJobEvent', () => {
|
||||
it('queued IDEA_GRILL → ideaStatuses[id] = grilling', () => {
|
||||
useIdeaStore.getState().handleIdeaJobEvent({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: 'job-1',
|
||||
idea_id: 'idea-1',
|
||||
user_id: 'u-1',
|
||||
kind: 'IDEA_GRILL',
|
||||
status: 'queued',
|
||||
})
|
||||
const s = useIdeaStore.getState()
|
||||
expect(s.jobByIdea['idea-1']?.status).toBe('queued')
|
||||
expect(s.ideaStatuses['idea-1']).toBe('grilling')
|
||||
})
|
||||
|
||||
it('failed IDEA_GRILL → ideaStatuses[id] = grill_failed', () => {
|
||||
useIdeaStore.getState().handleIdeaJobEvent({
|
||||
type: 'claude_job_status',
|
||||
job_id: 'job-1',
|
||||
idea_id: 'idea-1',
|
||||
user_id: 'u-1',
|
||||
kind: 'IDEA_GRILL',
|
||||
status: 'failed',
|
||||
error: 'oops',
|
||||
})
|
||||
expect(useIdeaStore.getState().ideaStatuses['idea-1']).toBe('grill_failed')
|
||||
expect(useIdeaStore.getState().jobByIdea['idea-1']?.error).toBe('oops')
|
||||
})
|
||||
|
||||
it('failed IDEA_MAKE_PLAN → plan_failed', () => {
|
||||
useIdeaStore.getState().handleIdeaJobEvent({
|
||||
type: 'claude_job_status',
|
||||
job_id: 'job-2',
|
||||
idea_id: 'idea-2',
|
||||
user_id: 'u-1',
|
||||
kind: 'IDEA_MAKE_PLAN',
|
||||
status: 'failed',
|
||||
})
|
||||
expect(useIdeaStore.getState().ideaStatuses['idea-2']).toBe('plan_failed')
|
||||
})
|
||||
|
||||
it('done does NOT auto-derive status (server is source-of-truth)', () => {
|
||||
useIdeaStore.getState().setIdeaStatus('idea-3', 'grilled')
|
||||
useIdeaStore.getState().handleIdeaJobEvent({
|
||||
type: 'claude_job_status',
|
||||
job_id: 'job-3',
|
||||
idea_id: 'idea-3',
|
||||
user_id: 'u-1',
|
||||
kind: 'IDEA_GRILL',
|
||||
status: 'done',
|
||||
})
|
||||
expect(useIdeaStore.getState().ideaStatuses['idea-3']).toBe('grilled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIdeaStore — handleIdeaQuestionEvent', () => {
|
||||
it('non-open status removes question from list', () => {
|
||||
useIdeaStore.getState().initQuestions('idea-1', [
|
||||
{
|
||||
id: 'q-1',
|
||||
idea_id: 'idea-1',
|
||||
question: 'Q',
|
||||
options: null,
|
||||
status: 'open',
|
||||
created_at: '',
|
||||
expires_at: '',
|
||||
},
|
||||
])
|
||||
useIdeaStore.getState().handleIdeaQuestionEvent({
|
||||
op: 'U',
|
||||
entity: 'question',
|
||||
id: 'q-1',
|
||||
product_id: 'p-1',
|
||||
story_id: null,
|
||||
idea_id: 'idea-1',
|
||||
status: 'answered',
|
||||
})
|
||||
expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toEqual([])
|
||||
})
|
||||
|
||||
it('open status keeps existing list (no detail in payload)', () => {
|
||||
const q = {
|
||||
id: 'q-1',
|
||||
idea_id: 'idea-1',
|
||||
question: 'Q',
|
||||
options: null,
|
||||
status: 'open' as const,
|
||||
created_at: '',
|
||||
expires_at: '',
|
||||
}
|
||||
useIdeaStore.getState().initQuestions('idea-1', [q])
|
||||
useIdeaStore.getState().handleIdeaQuestionEvent({
|
||||
op: 'I',
|
||||
entity: 'question',
|
||||
id: 'q-2',
|
||||
product_id: 'p-1',
|
||||
story_id: null,
|
||||
idea_id: 'idea-1',
|
||||
status: 'open',
|
||||
})
|
||||
// List length blijft 1 (server-fetch leveert de detail)
|
||||
expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIdeaStore — clearForIdea', () => {
|
||||
it('removes job + status + questions for one idea, leaves others', () => {
|
||||
const s = useIdeaStore.getState()
|
||||
s.setJobStatus({
|
||||
job_id: 'j-1',
|
||||
idea_id: 'idea-1',
|
||||
kind: 'IDEA_GRILL',
|
||||
status: 'running',
|
||||
})
|
||||
s.setJobStatus({
|
||||
job_id: 'j-2',
|
||||
idea_id: 'idea-2',
|
||||
kind: 'IDEA_GRILL',
|
||||
status: 'running',
|
||||
})
|
||||
s.setIdeaStatus('idea-1', 'grilling')
|
||||
s.setIdeaStatus('idea-2', 'grilling')
|
||||
|
||||
s.clearForIdea('idea-1')
|
||||
|
||||
const after = useIdeaStore.getState()
|
||||
expect(after.jobByIdea['idea-1']).toBeUndefined()
|
||||
expect(after.jobByIdea['idea-2']).toBeDefined()
|
||||
expect(after.ideaStatuses['idea-1']).toBeUndefined()
|
||||
expect(after.ideaStatuses['idea-2']).toBe('grilling')
|
||||
})
|
||||
})
|
||||
688
actions/ideas.ts
Normal file
688
actions/ideas.ts
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
'use server'
|
||||
|
||||
// Server-actions voor de Idea-entity (M12). Volgt docs/patterns/server-action.md:
|
||||
// auth → demo-guard → rate-limit → zod-validate → user_id-scope-check → write
|
||||
// → revalidatePath. Idee is strikt user_id-only (zie M12 grill-keuze 8) — er
|
||||
// is GEEN productAccessFilter; idee is privé voor de eigenaar, ook als-ie
|
||||
// gekoppeld is aan een team-product.
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea'
|
||||
import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } from '@/lib/idea-status'
|
||||
import { nextIdeaCode } from '@/lib/idea-code-server'
|
||||
import { parsePlanMd } from '@/lib/idea-plan-parser'
|
||||
import { ACTIVE_JOB_STATUSES } from '@/lib/job-status'
|
||||
|
||||
import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client'
|
||||
|
||||
// Worker-presence: aligned met /api/realtime/solo.
|
||||
const WORKER_FRESH_MS = 15_000
|
||||
async function countActiveWorkers(userId: string): Promise<number> {
|
||||
return prisma.claudeWorker.count({
|
||||
where: {
|
||||
user_id: userId,
|
||||
last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
// Standaard error-shape voor consistente UI-rendering — zie ook actions/todos.ts.
|
||||
type ActionResult<T = void> =
|
||||
| { success: true; data?: T }
|
||||
| { error: string; code?: number; details?: unknown }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
|
||||
export async function createIdeaAction(input: {
|
||||
title: string
|
||||
description?: string | null
|
||||
product_id?: string | null
|
||||
}): Promise<ActionResult<{ id: string; code: string }>> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('create-idea', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
const parsed = ideaCreateSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const userId = session.userId
|
||||
// Atomair: code + create in dezelfde transactie zodat een crash tussenin geen
|
||||
// counter-gat veroorzaakt zonder bijbehorend idee.
|
||||
const idea = await prisma.$transaction(async (tx) => {
|
||||
const code = await nextIdeaCode(userId, tx)
|
||||
return tx.idea.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
product_id: parsed.data.product_id ?? null,
|
||||
code,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
select: { id: true, code: true },
|
||||
})
|
||||
})
|
||||
|
||||
revalidatePath('/ideas')
|
||||
return { success: true, data: idea }
|
||||
}
|
||||
|
||||
export async function updateIdeaAction(
|
||||
id: string,
|
||||
input: { title?: string; description?: string | null; product_id?: string | null },
|
||||
): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = ideaUpdateSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (!isIdeaEditable(idea.status)) {
|
||||
return { error: `Idee is niet bewerkbaar in status ${idea.status}`, code: 422 }
|
||||
}
|
||||
|
||||
await prisma.idea.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
|
||||
...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}),
|
||||
},
|
||||
})
|
||||
revalidatePath('/ideas')
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function archiveIdeaAction(id: string): Promise<ActionResult> {
|
||||
return setArchived(id, true)
|
||||
}
|
||||
|
||||
export async function unarchiveIdeaAction(id: string): Promise<ActionResult> {
|
||||
return setArchived(id, false)
|
||||
}
|
||||
|
||||
async function setArchived(id: string, archived: boolean): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const found = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!found) return { error: 'Idee niet gevonden', code: 404 }
|
||||
|
||||
await prisma.idea.update({ where: { id }, data: { archived } })
|
||||
revalidatePath('/ideas')
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function deleteIdeaAction(id: string): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true, pbi_id: true },
|
||||
})
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (idea.pbi_id !== null) {
|
||||
return {
|
||||
error: 'Verwijder eerst de gekoppelde PBI; daarna kun je het idee weggooien.',
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.idea.delete({ where: { id } })
|
||||
revalidatePath('/ideas')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown-edits (grill_md & plan_md handmatig fine-tunen)
|
||||
|
||||
export async function updateGrillMdAction(
|
||||
id: string,
|
||||
markdown: string,
|
||||
): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('edit-idea-md', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
const idea = await loadOwnedIdea(id, session.userId, ['status'])
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (!isGrillMdEditable(idea.status)) {
|
||||
return {
|
||||
error: `grill_md alleen bewerkbaar in GRILLED of PLAN_READY (huidige status: ${idea.status})`,
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.idea.update({ where: { id }, data: { grill_md: markdown } }),
|
||||
prisma.ideaLog.create({
|
||||
data: {
|
||||
idea_id: id,
|
||||
type: 'NOTE',
|
||||
content: 'User-edited grill_md',
|
||||
metadata: { length: markdown.length },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updatePlanMdAction(
|
||||
id: string,
|
||||
markdown: string,
|
||||
): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('edit-idea-md', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
const idea = await loadOwnedIdea(id, session.userId, ['status'])
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (!isPlanMdEditable(idea.status)) {
|
||||
return {
|
||||
error: `plan_md alleen bewerkbaar in PLAN_READY (huidige status: ${idea.status})`,
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate frontmatter — voorkomt dat een onparseerbaar plan in de DB belandt
|
||||
// en bij Materialiseer pas faalt.
|
||||
const parsed = parsePlanMd(markdown)
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
error: 'plan_md is niet parseerbaar',
|
||||
code: 422,
|
||||
details: parsed.errors,
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.idea.update({ where: { id }, data: { plan_md: markdown } }),
|
||||
prisma.ideaLog.create({
|
||||
data: {
|
||||
idea_id: id,
|
||||
type: 'NOTE',
|
||||
content: 'User-edited plan_md',
|
||||
metadata: { length: markdown.length },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download — geeft de raw markdown terug; UI bouwt een Blob.
|
||||
|
||||
export async function downloadIdeaMdAction(
|
||||
id: string,
|
||||
kind: 'grill' | 'plan',
|
||||
): Promise<ActionResult<{ filename: string; markdown: string }>> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
// Demo MAG downloaden — read-only operatie, geen mutatie.
|
||||
|
||||
const idea = await loadOwnedIdea(id, session.userId, ['code', 'grill_md', 'plan_md'])
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
|
||||
const md = kind === 'grill' ? idea.grill_md : idea.plan_md
|
||||
if (!md) {
|
||||
return { error: `Geen ${kind}_md beschikbaar voor dit idee`, code: 404 }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { filename: `${idea.code}-${kind}.md`, markdown: md },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job-triggers (Grill Me / Make Plan / Cancel)
|
||||
|
||||
const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY']
|
||||
const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY']
|
||||
|
||||
export async function startGrillJobAction(id: string): Promise<ActionResult<{ job_id: string }>> {
|
||||
return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM)
|
||||
}
|
||||
|
||||
export async function startMakePlanJobAction(id: string): Promise<ActionResult<{ job_id: string }>> {
|
||||
return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM)
|
||||
}
|
||||
|
||||
async function startIdeaJob(
|
||||
id: string,
|
||||
kind: ClaudeJobKind,
|
||||
newStatus: IdeaStatus,
|
||||
allowedFrom: IdeaStatus[],
|
||||
): Promise<ActionResult<{ job_id: string }>> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('start-idea-job', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
// Laad idee + product (voor repo_url-validatie)
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
product_id: true,
|
||||
product: { select: { id: true, repo_url: true } },
|
||||
},
|
||||
})
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (!allowedFrom.includes(idea.status)) {
|
||||
return {
|
||||
error: `Actie niet toegestaan in status ${idea.status}`,
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
if (!canTransition(idea.status, newStatus)) {
|
||||
return { error: `Status-transitie ${idea.status}→${newStatus} ongeldig`, code: 422 }
|
||||
}
|
||||
|
||||
// Product-met-repo verplicht (M12 grill-keuze 3)
|
||||
if (!idea.product_id || !idea.product?.repo_url) {
|
||||
return {
|
||||
error: 'Idee moet gekoppeld zijn aan een product met repo_url voordat je dit kunt starten.',
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency: weiger als er al een actieve job loopt voor dit idee.
|
||||
const existing = await prisma.claudeJob.findFirst({
|
||||
where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
return {
|
||||
error: 'Er loopt al een actieve agent voor dit idee.',
|
||||
code: 409,
|
||||
details: { job_id: existing.id },
|
||||
}
|
||||
}
|
||||
|
||||
// Worker-presence — server-side check, naast UI-side disabled-rule.
|
||||
const workers = await countActiveWorkers(session.userId)
|
||||
if (workers === 0) {
|
||||
return {
|
||||
error: 'Geen Claude-worker actief. Start een lokale wait_for_job-loop en probeer opnieuw.',
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic: create job + flip idea-status + log.
|
||||
const job = await prisma.$transaction(async (tx) => {
|
||||
const j = await tx.claudeJob.create({
|
||||
data: {
|
||||
user_id: session.userId,
|
||||
product_id: idea.product_id!,
|
||||
idea_id: id,
|
||||
kind,
|
||||
status: 'QUEUED',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
await tx.idea.update({ where: { id }, data: { status: newStatus } })
|
||||
await tx.ideaLog.create({
|
||||
data: {
|
||||
idea_id: id,
|
||||
type: 'JOB_EVENT',
|
||||
content: `${kind} queued`,
|
||||
metadata: { job_id: j.id, kind },
|
||||
},
|
||||
})
|
||||
return j
|
||||
})
|
||||
|
||||
// Manual pg_notify zoals enqueueClaudeJobAction in actions/claude-jobs.ts.
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
idea_id: id,
|
||||
user_id: session.userId,
|
||||
product_id: idea.product_id,
|
||||
kind,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
|
||||
revalidatePath('/ideas')
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true, data: { job_id: job.id } }
|
||||
}
|
||||
|
||||
export async function cancelIdeaJobAction(id: string): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true, status: true, grill_md: true, plan_md: true },
|
||||
})
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
|
||||
// Vind de actieve job — meest recente in QUEUED|CLAIMED|RUNNING.
|
||||
const job = await prisma.claudeJob.findFirst({
|
||||
where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: { id: true, kind: true },
|
||||
})
|
||||
if (!job) return { error: 'Geen actieve job om te annuleren', code: 404 }
|
||||
|
||||
// Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er
|
||||
// al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al
|
||||
// plan_md was (re-plan-cancel), anders GRILLED.
|
||||
let revertStatus: IdeaStatus
|
||||
if (job.kind === 'IDEA_GRILL') {
|
||||
revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT'
|
||||
} else if (job.kind === 'IDEA_MAKE_PLAN') {
|
||||
revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED'
|
||||
} else {
|
||||
return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 }
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.claudeJob.update({
|
||||
where: { id: job.id },
|
||||
data: { status: 'CANCELLED', finished_at: new Date(), error: 'user_cancelled' },
|
||||
}),
|
||||
prisma.idea.update({ where: { id }, data: { status: revertStatus } }),
|
||||
prisma.ideaLog.create({
|
||||
data: {
|
||||
idea_id: id,
|
||||
type: 'JOB_EVENT',
|
||||
content: `${job.kind} cancelled by user`,
|
||||
metadata: { job_id: job.id, revert_status: revertStatus },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_status',
|
||||
job_id: job.id,
|
||||
idea_id: id,
|
||||
user_id: session.userId,
|
||||
kind: job.kind,
|
||||
status: 'cancelled',
|
||||
})}::text)
|
||||
`
|
||||
|
||||
revalidatePath('/ideas')
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Materialize: parse plan_md → INSERT PBI + stories + taken (atomic)
|
||||
|
||||
const PBI_AUTO_RE = /^PBI-(\d+)$/
|
||||
const STORY_AUTO_RE = /^ST-(\d+)$/
|
||||
const TASK_AUTO_RE = /^T-(\d+)$/
|
||||
|
||||
function nextNumber(existing: (string | null)[], re: RegExp): number {
|
||||
let max = 0
|
||||
for (const c of existing) {
|
||||
if (!c) continue
|
||||
const m = c.match(re)
|
||||
if (m) {
|
||||
const n = Number.parseInt(m[1], 10)
|
||||
if (!Number.isNaN(n) && n > max) max = n
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
export async function materializeIdeaPlanAction(
|
||||
id: string,
|
||||
): Promise<ActionResult<{ pbi_id: string; pbi_code: string; story_ids: string[]; task_ids: string[] }>> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('materialize-idea', session.userId)
|
||||
if (limited) return limited
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true, status: true, product_id: true, plan_md: true },
|
||||
})
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (idea.status !== 'PLAN_READY') {
|
||||
return {
|
||||
error: `Materialiseren alleen toegestaan in PLAN_READY (huidige status: ${idea.status})`,
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
if (!idea.product_id) {
|
||||
return { error: 'Idee mist een gekoppeld product', code: 422 }
|
||||
}
|
||||
if (!idea.plan_md) {
|
||||
return { error: 'Idee heeft geen plan_md', code: 422 }
|
||||
}
|
||||
|
||||
const parsed = parsePlanMd(idea.plan_md)
|
||||
if (!parsed.ok) {
|
||||
return { error: 'plan_md is niet parseerbaar', code: 422, details: parsed.errors }
|
||||
}
|
||||
|
||||
const productId = idea.product_id
|
||||
const plan = parsed.plan
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Codes: één keer SELECT max per type binnen de transactie. Bij P2002
|
||||
// (race met andere materialize) abort de transactie en gooien we 409.
|
||||
const [existingPbis, existingStories, existingTasks] = await Promise.all([
|
||||
tx.pbi.findMany({ where: { product_id: productId }, select: { code: true } }),
|
||||
tx.story.findMany({ where: { product_id: productId }, select: { code: true } }),
|
||||
tx.task.findMany({ where: { product_id: productId }, select: { code: true } }),
|
||||
])
|
||||
let nextPbiN = nextNumber(existingPbis.map((p) => p.code), PBI_AUTO_RE)
|
||||
let nextStoryN = nextNumber(existingStories.map((s) => s.code), STORY_AUTO_RE)
|
||||
let nextTaskN = nextNumber(existingTasks.map((t) => t.code), TASK_AUTO_RE)
|
||||
|
||||
// sort_order: vraag de huidige max binnen het product op (per priority)
|
||||
const lastPbi = await tx.pbi.findFirst({
|
||||
where: { product_id: productId, priority: plan.pbi.priority },
|
||||
orderBy: { sort_order: 'desc' },
|
||||
select: { sort_order: true },
|
||||
})
|
||||
const pbiSortOrder = (lastPbi?.sort_order ?? 0) + 1.0
|
||||
|
||||
const pbi = await tx.pbi.create({
|
||||
data: {
|
||||
product_id: productId,
|
||||
code: `PBI-${nextPbiN++}`,
|
||||
title: plan.pbi.title,
|
||||
description: plan.pbi.description ?? null,
|
||||
priority: plan.pbi.priority,
|
||||
sort_order: pbiSortOrder,
|
||||
},
|
||||
select: { id: true, code: true },
|
||||
})
|
||||
|
||||
const storyIds: string[] = []
|
||||
const taskIds: string[] = []
|
||||
|
||||
for (let si = 0; si < plan.stories.length; si++) {
|
||||
const s = plan.stories[si]
|
||||
const story = await tx.story.create({
|
||||
data: {
|
||||
pbi_id: pbi.id,
|
||||
product_id: productId,
|
||||
code: `ST-${String(nextStoryN++).padStart(3, '0')}`,
|
||||
title: s.title,
|
||||
description: s.description ?? null,
|
||||
acceptance_criteria: s.acceptance_criteria ?? null,
|
||||
priority: s.priority,
|
||||
sort_order: si + 1, // sequential within PBI
|
||||
status: 'OPEN',
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
storyIds.push(story.id)
|
||||
|
||||
for (let ti = 0; ti < s.tasks.length; ti++) {
|
||||
const t = s.tasks[ti]
|
||||
const task = await tx.task.create({
|
||||
data: {
|
||||
story_id: story.id,
|
||||
product_id: productId,
|
||||
code: `T-${nextTaskN++}`,
|
||||
title: t.title,
|
||||
description: t.description ?? null,
|
||||
implementation_plan: t.implementation_plan ?? null,
|
||||
priority: t.priority,
|
||||
sort_order: ti + 1,
|
||||
status: 'TO_DO',
|
||||
verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL',
|
||||
verify_only: t.verify_only ?? false,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
taskIds.push(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Link idea → PBI + status PLANNED
|
||||
await tx.idea.update({
|
||||
where: { id },
|
||||
data: { pbi_id: pbi.id, status: 'PLANNED' },
|
||||
})
|
||||
|
||||
// Audit log
|
||||
await tx.ideaLog.create({
|
||||
data: {
|
||||
idea_id: id,
|
||||
type: 'PLAN_RESULT',
|
||||
content: `Materialized into ${pbi.code} (${plan.stories.length} stories, ${taskIds.length} tasks)`,
|
||||
metadata: {
|
||||
pbi_id: pbi.id,
|
||||
pbi_code: pbi.code,
|
||||
story_count: storyIds.length,
|
||||
task_count: taskIds.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { pbi_id: pbi.id, pbi_code: pbi.code, story_ids: storyIds, task_ids: taskIds }
|
||||
})
|
||||
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
revalidatePath(`/products/${productId}/backlog`)
|
||||
return { success: true, data: result }
|
||||
} catch (err) {
|
||||
// P2002 op code = race met andere materialize. Andere fouten = bug.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes('P2002') || msg.includes('Unique constraint')) {
|
||||
return {
|
||||
error: 'Code-conflict tijdens materialiseren (race). Probeer opnieuw.',
|
||||
code: 409,
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-link: een idee in PLANNED waarvan de PBI handmatig is verwijderd
|
||||
// (Pbi.id → null door de SetNull-FK). Gebruiker klikt expliciet "Re-link plan"
|
||||
// om terug naar PLAN_READY te gaan en eventueel opnieuw te materialiseren.
|
||||
|
||||
export async function relinkIdeaPlanAction(id: string): Promise<ActionResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
select: { id: true, status: true, pbi_id: true },
|
||||
})
|
||||
if (!idea) return { error: 'Idee niet gevonden', code: 404 }
|
||||
if (idea.status !== 'PLANNED' || idea.pbi_id !== null) {
|
||||
return {
|
||||
error: 'Re-link kan alleen wanneer status=PLANNED én PBI is verwijderd',
|
||||
code: 422,
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.idea.update({ where: { id }, data: { status: 'PLAN_READY' } }),
|
||||
prisma.ideaLog.create({
|
||||
data: {
|
||||
idea_id: id,
|
||||
type: 'NOTE',
|
||||
content: 'PBI was deleted; relinked to PLAN_READY',
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath(`/ideas/${id}`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
type IdeaSelect = Array<keyof Idea>
|
||||
|
||||
async function loadOwnedIdea<S extends IdeaSelect>(
|
||||
id: string,
|
||||
userId: string,
|
||||
fields: S,
|
||||
): Promise<Pick<Idea, S[number]> | null> {
|
||||
const select = Object.fromEntries(fields.map((f) => [f, true])) as {
|
||||
[K in S[number]]: true
|
||||
}
|
||||
return prisma.idea.findFirst({
|
||||
where: { id, user_id: userId },
|
||||
select,
|
||||
}) as Promise<Pick<Idea, S[number]> | null>
|
||||
}
|
||||
|
|
@ -241,6 +241,60 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo
|
|||
return { success: true }
|
||||
}
|
||||
|
||||
// M12: promote a Todo into a DRAFT Idea. Anders dan Todo→PBI/Story (die de
|
||||
// todo deleteert) ARCHIVEREN we de todo hier — het idee houdt zelf de
|
||||
// planningsgeschiedenis bij, en de archived todo bewaart het oorspronkelijke
|
||||
// vertrekpunt.
|
||||
export async function promoteTodoToIdeaAction(todoId: string): Promise<
|
||||
{ success: true; idea_id: string; idea_code: string } | { error: string; code?: number }
|
||||
> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
if (!todoId) return { error: 'todoId is verplicht', code: 422 }
|
||||
|
||||
const todo = await prisma.todo.findFirst({
|
||||
where: { id: todoId, user_id: session.userId },
|
||||
select: { id: true, title: true, description: true, product_id: true, archived: true },
|
||||
})
|
||||
if (!todo) return { error: 'Todo niet gevonden', code: 404 }
|
||||
if (todo.archived) return { error: 'Todo is al gearchiveerd', code: 422 }
|
||||
|
||||
const userId = session.userId
|
||||
// Lazy-import om dit server-only bestand niet te dwingen in een client bundle.
|
||||
const { nextIdeaCode } = await import('@/lib/idea-code-server')
|
||||
|
||||
const idea = await prisma.$transaction(async (tx) => {
|
||||
const code = await nextIdeaCode(userId, tx)
|
||||
const created = await tx.idea.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
product_id: todo.product_id,
|
||||
code,
|
||||
title: todo.title,
|
||||
description: todo.description ?? null,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
select: { id: true, code: true },
|
||||
})
|
||||
await tx.todo.update({ where: { id: todoId }, data: { archived: true } })
|
||||
await tx.ideaLog.create({
|
||||
data: {
|
||||
idea_id: created.id,
|
||||
type: 'NOTE',
|
||||
content: `Promoted from Todo ${todoId}`,
|
||||
metadata: { source_todo_id: todoId },
|
||||
},
|
||||
})
|
||||
return created
|
||||
})
|
||||
|
||||
revalidatePath('/ideas')
|
||||
revalidatePath('/todos')
|
||||
return { success: true, idea_id: idea.id, idea_code: idea.code }
|
||||
}
|
||||
|
||||
export async function updateRolesAction(roles: string[]) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
|
|
|
|||
98
app/(app)/ideas/[id]/page.tsx
Normal file
98
app/(app)/ideas/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getIronSession } from 'iron-session'
|
||||
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { ideaToDto } from '@/lib/idea-dto'
|
||||
import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ tab?: string }>
|
||||
}
|
||||
|
||||
export default async function IdeaDetailPage({ params, searchParams }: PageProps) {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
if (!session.userId) notFound() // proxy.ts redirect zou ons al moeten hebben
|
||||
|
||||
const { id } = await params
|
||||
const { tab } = await searchParams
|
||||
|
||||
// M12: strikt user_id-only — 404 (niet 403) voor andere users (anti-enum).
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: session.userId },
|
||||
include: {
|
||||
product: { select: { id: true, name: true, repo_url: true } },
|
||||
pbi: { select: { id: true, code: true, title: true } },
|
||||
},
|
||||
})
|
||||
if (!idea) notFound()
|
||||
|
||||
// Producten voor de "koppel product"-dropdown in de form-tab.
|
||||
const products = await prisma.product.findMany({
|
||||
where: { ...productAccessFilter(session.userId), archived: false },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, repo_url: true },
|
||||
})
|
||||
|
||||
// Recent logs (laatste 100) voor de Timeline-tab.
|
||||
const logs = await prisma.ideaLog.findMany({
|
||||
where: { idea_id: id },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
content: true,
|
||||
metadata: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Open vragen voor dit idee — voor de Timeline-tab.
|
||||
const questions = await prisma.claudeQuestion.findMany({
|
||||
where: { idea_id: id },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
question: true,
|
||||
options: true,
|
||||
status: true,
|
||||
answer: true,
|
||||
created_at: true,
|
||||
expires_at: true,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<IdeaDetailLayout
|
||||
idea={ideaToDto(idea)}
|
||||
grill_md={idea.grill_md}
|
||||
plan_md={idea.plan_md}
|
||||
products={products}
|
||||
logs={logs.map((l) => ({
|
||||
id: l.id,
|
||||
type: l.type,
|
||||
content: l.content,
|
||||
metadata: l.metadata,
|
||||
created_at: l.created_at.toISOString(),
|
||||
}))}
|
||||
questions={questions.map((q) => ({
|
||||
id: q.id,
|
||||
question: q.question,
|
||||
options: Array.isArray(q.options) ? (q.options as string[]) : null,
|
||||
status: q.status as 'open' | 'answered' | 'cancelled' | 'expired',
|
||||
answer: q.answer ?? null,
|
||||
created_at: q.created_at.toISOString(),
|
||||
expires_at: q.expires_at.toISOString(),
|
||||
}))}
|
||||
isDemo={session.isDemo ?? false}
|
||||
initialTab={tab ?? 'idee'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
48
app/(app)/ideas/page.tsx
Normal file
48
app/(app)/ideas/page.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { ideaToDto } from '@/lib/idea-dto'
|
||||
import { IdeaList } from '@/components/ideas/idea-list'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function IdeasPage() {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
||||
// M12: idee is strikt user_id-only (geen productAccessFilter — Q8).
|
||||
const ideas = await prisma.idea.findMany({
|
||||
where: { user_id: session.userId, archived: false },
|
||||
orderBy: { created_at: 'desc' },
|
||||
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||
take: 200,
|
||||
})
|
||||
|
||||
// Productenlijst voor de filter-dropdown + voor "Nieuw idee"-form.
|
||||
// Producten zijn product-scoped (kan team-shared zijn) — productAccessFilter
|
||||
// is hier dus wél juist.
|
||||
const products = await prisma.product.findMany({
|
||||
where: { ...productAccessFilter(session.userId), archived: false },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true, repo_url: true },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full">
|
||||
<header className="mb-6 flex items-baseline justify-between">
|
||||
<h1 className="text-xl font-medium text-foreground">Ideeën</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<IdeaList
|
||||
ideas={ideas.map((i) => ideaToDto(i))}
|
||||
products={products}
|
||||
isDemo={session.isDemo ?? false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
app/api/ideas/[id]/route.ts
Normal file
91
app/api/ideas/[id]/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Per-idea REST endpoints (M12). user_id-strict scope, 404 (niet 403) bij
|
||||
// foreign user om enumeratie te vermijden.
|
||||
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ideaUpdateSchema } from '@/lib/schemas/idea'
|
||||
import { isIdeaEditable } from '@/lib/idea-status'
|
||||
import { ideaToDto } from '@/lib/idea-dto'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function GET(request: Request, ctx: RouteContext) {
|
||||
const auth = await authenticateApiRequest(request)
|
||||
if ('error' in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status })
|
||||
}
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: auth.userId },
|
||||
include: {
|
||||
product: { select: { id: true, name: true, repo_url: true } },
|
||||
pbi: { select: { id: true, code: true, title: true } },
|
||||
},
|
||||
})
|
||||
if (!idea) {
|
||||
return Response.json({ error: 'Idee niet gevonden' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Recente logs (max 50) — handig voor MCP tools die context willen ophalen.
|
||||
const logs = await prisma.ideaLog.findMany({
|
||||
where: { idea_id: id },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 50,
|
||||
select: { id: true, type: true, content: true, metadata: true, created_at: true },
|
||||
})
|
||||
|
||||
return Response.json({ idea: ideaToDto(idea), logs })
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, ctx: RouteContext) {
|
||||
const auth = await authenticateApiRequest(request)
|
||||
if ('error' in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status })
|
||||
}
|
||||
if (auth.isDemo) {
|
||||
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await ctx.params
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
|
||||
}
|
||||
const parsed = ideaUpdateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
|
||||
}
|
||||
|
||||
const idea = await prisma.idea.findFirst({
|
||||
where: { id, user_id: auth.userId },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
if (!idea) {
|
||||
return Response.json({ error: 'Idee niet gevonden' }, { status: 404 })
|
||||
}
|
||||
if (!isIdeaEditable(idea.status)) {
|
||||
return Response.json(
|
||||
{ error: `Idee niet bewerkbaar in status ${idea.status}` },
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await prisma.idea.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
|
||||
...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}),
|
||||
},
|
||||
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||
})
|
||||
|
||||
return Response.json({ idea: ideaToDto(updated) })
|
||||
}
|
||||
94
app/api/ideas/route.ts
Normal file
94
app/api/ideas/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// REST endpoints voor de Idee-entity (M12).
|
||||
// - Strikt user_id-only — geen productAccessFilter.
|
||||
// - Auth via session OF API-token (zelfde patroon als /api/todos).
|
||||
// - Demo blokkeert POST/PATCH/DELETE (proxy.ts laag + 403 hier als second-line).
|
||||
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ideaCreateSchema } from '@/lib/schemas/idea'
|
||||
import { ideaStatusFromApi, ideaStatusToApi } from '@/lib/idea-status'
|
||||
import { nextIdeaCode } from '@/lib/idea-code-server'
|
||||
import { ideaToDto } from '@/lib/idea-dto'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = await authenticateApiRequest(request)
|
||||
if ('error' in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const archivedParam = url.searchParams.get('archived')
|
||||
const productIdParam = url.searchParams.get('product_id')
|
||||
const statusParam = url.searchParams.get('status')
|
||||
|
||||
const archived =
|
||||
archivedParam === 'true' ? true : archivedParam === 'false' ? false : undefined
|
||||
const status = statusParam ? ideaStatusFromApi(statusParam) ?? undefined : undefined
|
||||
|
||||
const ideas = await prisma.idea.findMany({
|
||||
where: {
|
||||
user_id: auth.userId,
|
||||
...(archived !== undefined ? { archived } : {}),
|
||||
...(productIdParam ? { product_id: productIdParam } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 200,
|
||||
})
|
||||
|
||||
return Response.json({ ideas: ideas.map(ideaToDto) })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await authenticateApiRequest(request)
|
||||
if ('error' in auth) {
|
||||
return Response.json({ error: auth.error }, { status: auth.status })
|
||||
}
|
||||
if (auth.isDemo) {
|
||||
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
|
||||
}
|
||||
const parsed = ideaCreateSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
|
||||
}
|
||||
|
||||
// Optionele product-binding: alleen toelaten als gebruiker eigenaar/member is.
|
||||
if (parsed.data.product_id) {
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: parsed.data.product_id, user_id: auth.userId, archived: false },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) {
|
||||
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
const userId = auth.userId
|
||||
const idea = await prisma.$transaction(async (tx) => {
|
||||
const code = await nextIdeaCode(userId, tx)
|
||||
return tx.idea.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
product_id: parsed.data.product_id ?? null,
|
||||
code,
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||
})
|
||||
})
|
||||
|
||||
return Response.json(
|
||||
{ idea: { ...ideaToDto(idea), status: ideaStatusToApi(idea.status) } },
|
||||
{ status: 201 },
|
||||
)
|
||||
}
|
||||
|
|
@ -26,17 +26,49 @@ const CHANNEL = 'scrum4me_changes'
|
|||
const HEARTBEAT_MS = 25_000
|
||||
const HARD_CLOSE_MS = 240_000
|
||||
|
||||
interface NotifyPayload {
|
||||
// Question-payloads: emitted by the notify_question_change trigger on
|
||||
// claude_questions. story_id and idea_id are mutually exclusive (DB-level
|
||||
// check-constraint added in M12).
|
||||
interface QuestionPayload {
|
||||
op: 'I' | 'U'
|
||||
entity: 'task' | 'story' | 'question'
|
||||
entity: 'question'
|
||||
id: string
|
||||
product_id: string
|
||||
story_id?: string
|
||||
story_id?: string | null
|
||||
task_id?: string | null
|
||||
idea_id?: string | null
|
||||
assignee_id?: string | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
// Idea-job-payloads: emitted by actions/ideas.ts (startGrillJobAction etc.)
|
||||
// via prisma.$executeRaw pg_notify. Always carries user_id + idea_id + kind.
|
||||
interface IdeaJobPayload {
|
||||
type: 'claude_job_enqueued' | 'claude_job_status'
|
||||
job_id: string
|
||||
idea_id: string
|
||||
user_id: string
|
||||
product_id?: string | null
|
||||
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
|
||||
status: string
|
||||
}
|
||||
|
||||
type NotifyPayload = QuestionPayload | IdeaJobPayload
|
||||
|
||||
function isQuestionPayload(p: NotifyPayload): p is QuestionPayload {
|
||||
return 'entity' in p && p.entity === 'question'
|
||||
}
|
||||
|
||||
function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload {
|
||||
return (
|
||||
'type' in p &&
|
||||
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
|
||||
'idea_id' in p &&
|
||||
'kind' in p &&
|
||||
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN')
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) {
|
||||
|
|
@ -53,6 +85,15 @@ export async function GET(request: NextRequest) {
|
|||
})
|
||||
const accessibleProductIds = new Set(products.map((p) => p.id))
|
||||
|
||||
// M12: idea-questions zijn strikt user_id-only (geen productAccessFilter).
|
||||
// We pre-fetchen de user's idea-ids zodat we snel kunnen filteren op het
|
||||
// SSE-pad — geen DB-call per event.
|
||||
const userIdeas = await prisma.idea.findMany({
|
||||
where: { user_id: userId },
|
||||
select: { id: true },
|
||||
})
|
||||
const accessibleIdeaIds = new Set(userIdeas.map((i) => i.id))
|
||||
|
||||
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
|
||||
if (!directUrl) {
|
||||
return Response.json(
|
||||
|
|
@ -115,7 +156,24 @@ export async function GET(request: NextRequest) {
|
|||
} catch {
|
||||
return
|
||||
}
|
||||
if (payload.entity !== 'question') return
|
||||
|
||||
if (isIdeaJobPayload(payload)) {
|
||||
// M12: idea-jobs zijn user-scoped, niet product-scoped.
|
||||
if (payload.user_id !== userId) return
|
||||
enqueue(`data: ${msg.payload}\n\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isQuestionPayload(payload)) return
|
||||
|
||||
// Idea-question: alleen voor de eigenaar van het idee.
|
||||
if (payload.idea_id) {
|
||||
if (!accessibleIdeaIds.has(payload.idea_id)) return
|
||||
enqueue(`data: ${msg.payload}\n\n`)
|
||||
return
|
||||
}
|
||||
|
||||
// Story-question: bestaande product-access-check.
|
||||
if (!accessibleProductIds.has(payload.product_id)) return
|
||||
enqueue(`data: ${msg.payload}\n\n`)
|
||||
})
|
||||
|
|
@ -132,6 +190,9 @@ export async function GET(request: NextRequest) {
|
|||
status: 'open',
|
||||
expires_at: { gt: new Date() },
|
||||
product_id: { in: products.map((p) => p.id) },
|
||||
// Skip idea-questions (story_id NULL) — story-questions only here.
|
||||
// Narrowing happens in the flatMap below — Prisma 7 rejects
|
||||
// `story_id: { not: null }` at runtime.
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
|
|
@ -150,7 +211,9 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
enqueue(
|
||||
`event: state\ndata: ${JSON.stringify({
|
||||
questions: openQuestions.map((q) => ({
|
||||
questions: openQuestions.flatMap((q) => {
|
||||
if (!q.story || q.story_id === null) return []
|
||||
return [{
|
||||
id: q.id,
|
||||
product_id: q.product_id,
|
||||
story_id: q.story_id,
|
||||
|
|
@ -162,7 +225,8 @@ export async function GET(request: NextRequest) {
|
|||
options: q.options,
|
||||
created_at: q.created_at.toISOString(),
|
||||
expires_at: q.expires_at.toISOString(),
|
||||
})),
|
||||
}]
|
||||
}),
|
||||
})}\n\n`,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,11 @@ type EntityPayload = {
|
|||
type JobPayload = {
|
||||
type: 'claude_job_enqueued' | 'claude_job_status'
|
||||
job_id: string
|
||||
task_id: string
|
||||
task_id?: string | null
|
||||
// M12: idea-jobs zetten kind + idea_id ipv task_id. Solo filtert die weg
|
||||
// (idea-jobs horen op /api/realtime/notifications, niet op het Solo Paneel).
|
||||
idea_id?: string | null
|
||||
kind?: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
|
||||
user_id: string
|
||||
product_id: string
|
||||
status: string
|
||||
|
|
@ -77,6 +81,8 @@ function shouldEmit(
|
|||
userId: string,
|
||||
): boolean {
|
||||
if (isJobPayload(payload)) {
|
||||
// M12: skip idea-jobs (kind=IDEA_*) — die horen op /api/realtime/notifications.
|
||||
if (payload.kind === 'IDEA_GRILL' || payload.kind === 'IDEA_MAKE_PLAN') return false
|
||||
return payload.user_id === userId && payload.product_id === productId
|
||||
}
|
||||
|
||||
|
|
|
|||
55
components/ideas/download-md-button.tsx
Normal file
55
components/ideas/download-md-button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
// DownloadMdButton — download grill_md of plan_md als .md-bestand.
|
||||
// Demo MAG downloaden (read-only). Server-action returnt md-string; client
|
||||
// bouwt een Blob + anchor + click().
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { downloadIdeaMdAction } from '@/actions/ideas'
|
||||
|
||||
interface Props {
|
||||
ideaId: string
|
||||
kind: 'grill' | 'plan'
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
export function DownloadMdButton({ ideaId, kind, hasContent }: Props) {
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
function handleClick() {
|
||||
startTransition(async () => {
|
||||
const r = await downloadIdeaMdAction(ideaId, kind)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
if (!r.data) return
|
||||
const blob = new Blob([r.data.markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = r.data.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
disabled={pending || !hasContent}
|
||||
title={hasContent ? `Download ${kind}_md` : 'Geen content'}
|
||||
>
|
||||
<Download className="size-3.5 mr-1" />
|
||||
.md
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
385
components/ideas/idea-detail-layout.tsx
Normal file
385
components/ideas/idea-detail-layout.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
'use client'
|
||||
|
||||
// IdeaDetailLayout — top-level container voor /ideas/[id].
|
||||
// Bevat: header (titel + status-badge + row-actions), tab-switcher
|
||||
// (Idee/Grill/Plan/Timeline), en per-tab content.
|
||||
//
|
||||
// URL-based tabs (?tab=grill) — bookmarkable + refresh-safe.
|
||||
// Md-editor (T-511), timeline (T-512), pbi-link-card (T-512) komen later.
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, ExternalLink } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { getIdeaStatusBadge } from '@/lib/idea-status-colors'
|
||||
import type { IdeaStatusApi } from '@/lib/idea-status'
|
||||
import { isIdeaEditable } from '@/lib/idea-status'
|
||||
import type { IdeaDto } from '@/lib/idea-dto'
|
||||
import { updateIdeaAction, archiveIdeaAction } from '@/actions/ideas'
|
||||
import { IdeaRowActions } from '@/components/ideas/idea-row-actions'
|
||||
import { IdeaMdEditor } from '@/components/ideas/idea-md-editor'
|
||||
import { IdeaPbiLinkCard } from '@/components/ideas/idea-pbi-link-card'
|
||||
import { IdeaTimeline } from '@/components/ideas/idea-timeline'
|
||||
import { DownloadMdButton } from '@/components/ideas/download-md-button'
|
||||
|
||||
const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = {
|
||||
draft: 'DRAFT',
|
||||
grilling: 'GRILLING',
|
||||
grill_failed: 'GRILL_FAILED',
|
||||
grilled: 'GRILLED',
|
||||
planning: 'PLANNING',
|
||||
plan_failed: 'PLAN_FAILED',
|
||||
plan_ready: 'PLAN_READY',
|
||||
planned: 'PLANNED',
|
||||
}
|
||||
|
||||
type TabKey = 'idee' | 'grill' | 'plan' | 'timeline'
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'idee', label: 'Idee' },
|
||||
{ key: 'grill', label: 'Grill' },
|
||||
{ key: 'plan', label: 'Plan' },
|
||||
{ key: 'timeline', label: 'Timeline' },
|
||||
]
|
||||
|
||||
interface IdeaLog {
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
metadata: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface IdeaQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[] | null
|
||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
answer: string | null
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
id: string
|
||||
name: string
|
||||
repo_url: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
idea: IdeaDto
|
||||
grill_md: string | null
|
||||
plan_md: string | null
|
||||
products: ProductOption[]
|
||||
logs: IdeaLog[]
|
||||
questions: IdeaQuestion[]
|
||||
isDemo: boolean
|
||||
initialTab: string
|
||||
}
|
||||
|
||||
export function IdeaDetailLayout({
|
||||
idea,
|
||||
grill_md,
|
||||
plan_md,
|
||||
products,
|
||||
logs,
|
||||
questions,
|
||||
isDemo,
|
||||
initialTab,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
const tab = (TABS.some((t) => t.key === initialTab) ? initialTab : 'idee') as TabKey
|
||||
|
||||
function setTab(key: TabKey) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('tab', key)
|
||||
router.replace(`/ideas/${idea.id}?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
|
||||
function handleArchive() {
|
||||
if (isDemo) return
|
||||
if (!confirm('Idee archiveren?')) return
|
||||
startTransition(async () => {
|
||||
const r = await archiveIdeaAction(idea.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success('Idee gearchiveerd')
|
||||
router.push('/ideas')
|
||||
})
|
||||
}
|
||||
|
||||
const badge = getIdeaStatusBadge(API_TO_DB[idea.status])
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full space-y-6">
|
||||
{/* Breadcrumb / back-link */}
|
||||
<Link
|
||||
href="/ideas"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Alle ideeën
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-mono text-xs text-muted-foreground">{idea.code}</p>
|
||||
<h1 className="text-2xl font-medium text-foreground">{idea.title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={badge.classes + (badge.pulse ? ' animate-pulse' : '')}>
|
||||
{badge.label}
|
||||
</span>
|
||||
{idea.product ? (
|
||||
<Link
|
||||
href={`/products/${idea.product.id}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
{idea.product.name}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm italic text-muted-foreground">geen product</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IdeaRowActions idea={idea} isDemo={isDemo} onArchive={handleArchive} />
|
||||
</header>
|
||||
|
||||
{/* PBI-link card / Re-link banner bij PLANNED */}
|
||||
<IdeaPbiLinkCard idea={idea} isDemo={isDemo} />
|
||||
|
||||
{/* Tab-switcher */}
|
||||
<nav className="border-b border-input flex gap-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.key === 'timeline' && (logs.length > 0 || questions.length > 0) ? (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({logs.length + questions.length})
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'idee' && (
|
||||
<IdeaFormSection
|
||||
idea={idea}
|
||||
products={products}
|
||||
isDemo={isDemo}
|
||||
pending={pending}
|
||||
/>
|
||||
)}
|
||||
{tab === 'grill' && (
|
||||
<MdSection
|
||||
kind="grill"
|
||||
markdown={grill_md}
|
||||
// M12 grill-keuze 12: grill_md editable in GRILLED + PLAN_READY.
|
||||
editable={
|
||||
!isDemo && (idea.status === 'grilled' || idea.status === 'plan_ready')
|
||||
}
|
||||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
{tab === 'plan' && (
|
||||
<MdSection
|
||||
kind="plan"
|
||||
markdown={plan_md}
|
||||
// M12 grill-keuze 12: plan_md editable alleen in PLAN_READY.
|
||||
editable={!isDemo && idea.status === 'plan_ready'}
|
||||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
{tab === 'timeline' && <IdeaTimeline logs={logs} questions={questions} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idee-tab: inline form (geen modal — de detailpagina IS de form).
|
||||
|
||||
interface FormProps {
|
||||
idea: IdeaDto
|
||||
products: ProductOption[]
|
||||
isDemo: boolean
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
function IdeaFormSection({ idea, products, isDemo, pending }: FormProps) {
|
||||
const router = useRouter()
|
||||
const editable =
|
||||
!isDemo &&
|
||||
isIdeaEditable(API_TO_DB[idea.status])
|
||||
const [title, setTitle] = useState(idea.title)
|
||||
const [description, setDescription] = useState(idea.description ?? '')
|
||||
const [productId, setProductId] = useState(idea.product_id ?? '')
|
||||
const [submitting, startSubmit] = useTransition()
|
||||
|
||||
const dirty =
|
||||
title !== idea.title ||
|
||||
description !== (idea.description ?? '') ||
|
||||
productId !== (idea.product_id ?? '')
|
||||
|
||||
function save() {
|
||||
startSubmit(async () => {
|
||||
const r = await updateIdeaAction(idea.id, {
|
||||
title,
|
||||
description: description || null,
|
||||
product_id: productId || null,
|
||||
})
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success('Opgeslagen')
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Titel</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={!editable || pending || submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Beschrijving</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
disabled={!editable || pending || submitting}
|
||||
placeholder="Korte beschrijving — wordt door Grill Me als startpunt gebruikt."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Product</label>
|
||||
<select
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value)}
|
||||
disabled={!editable || pending || submitting}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Geen product</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.repo_url ? '' : ' (geen repo — vereist voor Grill/Make Plan)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!editable && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Idee is niet bewerkbaar in status {idea.status.toUpperCase()}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!dirty || submitting}
|
||||
onClick={() => {
|
||||
setTitle(idea.title)
|
||||
setDescription(idea.description ?? '')
|
||||
setProductId(idea.product_id ?? '')
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" disabled={!dirty || submitting} onClick={save}>
|
||||
Opslaan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grill / Plan tab — read-only render. T-511 voegt edit-mode toe.
|
||||
|
||||
interface MdProps {
|
||||
kind: 'grill' | 'plan'
|
||||
markdown: string | null
|
||||
editable: boolean
|
||||
ideaId: string
|
||||
}
|
||||
|
||||
function MdSection({ kind, markdown, editable, ideaId }: MdProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<IdeaMdEditor
|
||||
ideaId={ideaId}
|
||||
kind={kind}
|
||||
initialValue={markdown ?? ''}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!markdown) {
|
||||
return (
|
||||
<div className="space-y-3 py-6">
|
||||
<p className="text-sm text-muted-foreground text-center italic">
|
||||
{kind === 'grill'
|
||||
? 'Nog geen grill-resultaat. Klik "Grill" in de header om te starten.'
|
||||
: 'Nog geen plan. Voltooi eerst de grill-fase en klik dan "Plan".'}
|
||||
</p>
|
||||
{editable && (
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
Schrijf zelf
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<DownloadMdButton ideaId={ideaId} kind={kind} hasContent={markdown !== null} />
|
||||
{editable && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
||||
Bewerk
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="rounded-md border border-input bg-surface-container p-4 text-sm whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto">
|
||||
{markdown}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
306
components/ideas/idea-list.tsx
Normal file
306
components/ideas/idea-list.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
'use client'
|
||||
|
||||
// IdeaList — top-level lijstpagina voor /ideas.
|
||||
// - Strikt user_id-only data (server haalt al; client filtert binnen die set).
|
||||
// - Filters: zoeken op titel, product-dropdown, status-multiselect.
|
||||
// - Klik op rij navigeert naar /ideas/[id]. Acties (Grill / Make Plan /
|
||||
// Materialiseer) staan in components/ideas/idea-row-actions.tsx (T-508).
|
||||
// - DemoTooltip rondom muteer-acties; bulk-archive blijft achter feature-flag
|
||||
// in T-508 en latere stories.
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { getIdeaStatusBadge } from '@/lib/idea-status-colors'
|
||||
import type { IdeaStatusApi } from '@/lib/idea-status'
|
||||
import type { IdeaDto } from '@/lib/idea-dto'
|
||||
import { createIdeaAction, archiveIdeaAction } from '@/actions/ideas'
|
||||
import { IdeaRowActions } from '@/components/ideas/idea-row-actions'
|
||||
|
||||
// Reverse mapping voor het renderen van de status-badge — DTO bevat lowercase
|
||||
// API-strings, het badge-helper verwacht DB-enum.
|
||||
const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = {
|
||||
draft: 'DRAFT',
|
||||
grilling: 'GRILLING',
|
||||
grill_failed: 'GRILL_FAILED',
|
||||
grilled: 'GRILLED',
|
||||
planning: 'PLANNING',
|
||||
plan_failed: 'PLAN_FAILED',
|
||||
plan_ready: 'PLAN_READY',
|
||||
planned: 'PLANNED',
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
id: string
|
||||
name: string
|
||||
repo_url: string | null
|
||||
}
|
||||
|
||||
interface IdeaListProps {
|
||||
ideas: IdeaDto[]
|
||||
products: ProductOption[]
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [
|
||||
{ value: 'draft', label: 'Concept' },
|
||||
{ value: 'grilling', label: 'Grillen' },
|
||||
{ value: 'grilled', label: 'Gegrilld' },
|
||||
{ value: 'planning', label: 'Plannen' },
|
||||
{ value: 'plan_ready', label: 'Plan klaar' },
|
||||
{ value: 'planned', label: 'Gepland' },
|
||||
{ value: 'grill_failed', label: 'Grill mislukt' },
|
||||
{ value: 'plan_failed', label: 'Plan mislukt' },
|
||||
]
|
||||
|
||||
export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
// Filter state
|
||||
const [search, setSearch] = useState('')
|
||||
const [productFilter, setProductFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<Set<IdeaStatusApi>>(new Set())
|
||||
|
||||
// Create-form state
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [newProductId, setNewProductId] = useState<string>('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
return ideas.filter((idea) => {
|
||||
if (q && !idea.title.toLowerCase().includes(q)) return false
|
||||
if (productFilter !== 'all') {
|
||||
if (productFilter === 'none' && idea.product_id !== null) return false
|
||||
if (productFilter !== 'none' && idea.product_id !== productFilter) return false
|
||||
}
|
||||
if (statusFilter.size > 0 && !statusFilter.has(idea.status)) return false
|
||||
return true
|
||||
})
|
||||
}, [ideas, search, productFilter, statusFilter])
|
||||
|
||||
function toggleStatus(s: IdeaStatusApi) {
|
||||
setStatusFilter((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(s)) next.delete(s)
|
||||
else next.add(s)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
if (isDemo) return
|
||||
const title = newTitle.trim()
|
||||
if (!title) {
|
||||
toast.error('Titel is verplicht')
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
const r = await createIdeaAction({
|
||||
title,
|
||||
description: newDescription.trim() || null,
|
||||
product_id: newProductId || null,
|
||||
})
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success(`Idee aangemaakt (${r.data?.code})`)
|
||||
setNewTitle('')
|
||||
setNewDescription('')
|
||||
setNewProductId('')
|
||||
setShowCreate(false)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
function handleArchive(id: string) {
|
||||
if (isDemo) return
|
||||
startTransition(async () => {
|
||||
const r = await archiveIdeaAction(id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success('Idee gearchiveerd')
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Top-bar: search + nieuw-knop */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Zoek op titel..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<select
|
||||
value={productFilter}
|
||||
onChange={(e) => setProductFilter(e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="all">Alle producten</option>
|
||||
<option value="none">Geen product</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-auto">
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
disabled={isDemo || isPending}
|
||||
>
|
||||
<Plus className="size-4 mr-1" />
|
||||
Nieuw idee
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status-chips als multi-select filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_FILTERS.map((s) => {
|
||||
const active = statusFilter.has(s.value)
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(s.value)}
|
||||
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
|
||||
active
|
||||
? 'bg-primary text-on-primary border-primary'
|
||||
: 'bg-background text-muted-foreground border-input hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Inline create form */}
|
||||
{showCreate && (
|
||||
<div className="rounded-md border border-input bg-surface-container p-4 space-y-3">
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="Titel van het idee..."
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Textarea
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder="Korte beschrijving (optioneel)..."
|
||||
rows={3}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<select
|
||||
value={newProductId}
|
||||
onChange={(e) => setNewProductId(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
<option value="">Geen product (kan later worden gekoppeld)</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.repo_url ? '' : ' (geen repo)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreate(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={isPending || !newTitle.trim()}>
|
||||
Aanmaken
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabel */}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
{ideas.length === 0
|
||||
? 'Nog geen ideeën — start hierboven met "Nieuw idee".'
|
||||
: 'Geen ideeën die aan de filters voldoen.'}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">Code</TableHead>
|
||||
<TableHead>Titel</TableHead>
|
||||
<TableHead className="w-40">Product</TableHead>
|
||||
<TableHead className="w-32">Status</TableHead>
|
||||
<TableHead className="w-72">Acties</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((idea) => {
|
||||
const badge = getIdeaStatusBadge(API_TO_DB[idea.status])
|
||||
return (
|
||||
<TableRow
|
||||
key={idea.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/ideas/${idea.id}`)}
|
||||
>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{idea.code}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{idea.title}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{idea.product?.name ?? <span className="italic">geen</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={badge.classes + (badge.pulse ? ' animate-pulse' : '')}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<IdeaRowActions
|
||||
idea={idea}
|
||||
isDemo={isDemo}
|
||||
onArchive={() => handleArchive(idea.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
components/ideas/idea-md-editor.tsx
Normal file
167
components/ideas/idea-md-editor.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
'use client'
|
||||
|
||||
// IdeaMdEditor — bewerk grill_md of plan_md.
|
||||
//
|
||||
// - kind='grill': geen yaml-validatie (vrije markdown).
|
||||
// - kind='plan' : preflight via parsePlanMd (server-side action herhaalt
|
||||
// validation, dit is alleen UX om eerder te falen).
|
||||
//
|
||||
// Save → updateGrillMdAction / updatePlanMdAction. Cmd/Ctrl+S triggert save.
|
||||
// LocalStorage-backed draft per idea+kind, restore bij heropening.
|
||||
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Save, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { parsePlanMd, type PlanParseError } from '@/lib/idea-plan-parser'
|
||||
import { updateGrillMdAction, updatePlanMdAction } from '@/actions/ideas'
|
||||
|
||||
type Kind = 'grill' | 'plan'
|
||||
|
||||
interface Props {
|
||||
ideaId: string
|
||||
kind: Kind
|
||||
initialValue: string
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// Lazily compute the seed: read draft from localStorage on first render, fall
|
||||
// back to initialValue. Avoids setState-in-useEffect for hydration.
|
||||
function readSeed(draftKey: string, initialValue: string): {
|
||||
value: string
|
||||
restored: boolean
|
||||
} {
|
||||
if (typeof window === 'undefined') return { value: initialValue, restored: false }
|
||||
const draft = window.localStorage.getItem(draftKey)
|
||||
if (draft && draft !== initialValue) return { value: draft, restored: true }
|
||||
return { value: initialValue, restored: false }
|
||||
}
|
||||
|
||||
export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) {
|
||||
const router = useRouter()
|
||||
const draftKey = `idea-md-draft-${ideaId}-${kind}`
|
||||
const [seed] = useState(() => readSeed(draftKey, initialValue))
|
||||
const [value, setValue] = useState(seed.value)
|
||||
const [submitErrors, setSubmitErrors] = useState<PlanParseError[]>([])
|
||||
const [submitting, startSubmit] = useTransition()
|
||||
|
||||
// Eenmalige toast voor restore — de seed is al toegepast bij mount.
|
||||
useEffect(() => {
|
||||
if (seed.restored) {
|
||||
toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.')
|
||||
}
|
||||
}, [seed.restored])
|
||||
|
||||
// Auto-save naar localStorage on change.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (value === initialValue) {
|
||||
window.localStorage.removeItem(draftKey)
|
||||
} else {
|
||||
window.localStorage.setItem(draftKey, value)
|
||||
}
|
||||
}, [value, initialValue, draftKey])
|
||||
|
||||
// Live yaml-validatie als afgeleide state — geen useEffect nodig.
|
||||
const validationErrors = useMemo<PlanParseError[]>(() => {
|
||||
if (kind !== 'plan') return []
|
||||
if (value === '' || value === initialValue) return []
|
||||
const r = parsePlanMd(value)
|
||||
return r.ok ? [] : r.errors
|
||||
}, [value, initialValue, kind])
|
||||
|
||||
// Combine: validation errors voor live feedback, submitErrors voor server-side details.
|
||||
const errors = submitErrors.length > 0 ? submitErrors : validationErrors
|
||||
|
||||
function save() {
|
||||
if (errors.length > 0 && kind === 'plan') {
|
||||
toast.error('Frontmatter heeft fouten — fix die eerst.')
|
||||
return
|
||||
}
|
||||
setSubmitErrors([])
|
||||
startSubmit(async () => {
|
||||
const r =
|
||||
kind === 'grill'
|
||||
? await updateGrillMdAction(ideaId, value)
|
||||
: await updatePlanMdAction(ideaId, value)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
if ('details' in r && Array.isArray(r.details)) {
|
||||
setSubmitErrors(r.details as PlanParseError[])
|
||||
}
|
||||
return
|
||||
}
|
||||
toast.success('Opgeslagen')
|
||||
window.localStorage.removeItem(draftKey)
|
||||
router.refresh()
|
||||
onCancel()
|
||||
})
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+S → save
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = value !== initialValue
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{errors.length > 0 && (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 space-y-1">
|
||||
<p className="text-xs font-medium text-status-blocked">
|
||||
{kind === 'plan' ? 'YAML-frontmatter fouten' : 'Validatiefouten'}
|
||||
</p>
|
||||
<ul className="text-xs text-status-blocked space-y-0.5">
|
||||
{errors.map((err, i) => (
|
||||
<li key={i}>
|
||||
{err.line ? `Regel ${err.line}: ` : ''}
|
||||
{err.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={24}
|
||||
className="font-mono text-sm leading-relaxed"
|
||||
placeholder={
|
||||
kind === 'grill'
|
||||
? '# Idee — ...\n## Scope\n...'
|
||||
: '---\npbi:\n title: ...\n priority: 2\nstories:\n - title: ...\n---\n\n# Overwegingen\n...'
|
||||
}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dirty ? 'Niet-opgeslagen wijzigingen — Cmd/Ctrl+S om op te slaan' : 'Geen wijzigingen'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={submitting}>
|
||||
<X className="size-3.5 mr-1" />
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={!dirty || submitting || (errors.length > 0 && kind === 'plan')}
|
||||
>
|
||||
<Save className="size-3.5 mr-1" />
|
||||
Opslaan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
components/ideas/idea-pbi-link-card.tsx
Normal file
85
components/ideas/idea-pbi-link-card.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
'use client'
|
||||
|
||||
// IdeaPbiLinkCard — toont de gekoppelde PBI bij PLANNED. Bij "stale link"
|
||||
// (status===PLANNED maar pbi_id===null, want PBI elders verwijderd via
|
||||
// de SetNull FK) tonen we de Re-link-banner.
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ExternalLink, Link2Off } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { relinkIdeaPlanAction } from '@/actions/ideas'
|
||||
import type { IdeaDto } from '@/lib/idea-dto'
|
||||
|
||||
interface Props {
|
||||
idea: IdeaDto
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
export function IdeaPbiLinkCard({ idea, isDemo }: Props) {
|
||||
const router = useRouter()
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
if (idea.status !== 'planned') return null
|
||||
|
||||
if (idea.pbi && idea.product_id) {
|
||||
return (
|
||||
<div className="rounded-md border border-status-done/30 bg-status-done/10 p-4 flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs uppercase tracking-wide text-status-done font-medium">
|
||||
Gepland
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Gematerialiseerd als{' '}
|
||||
<Link
|
||||
href={`/products/${idea.product_id}/backlog#pbi-${idea.pbi.code}`}
|
||||
className="font-medium text-status-done hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{idea.pbi.code} — {idea.pbi.title}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stale link — pbi_id === null maar status nog PLANNED.
|
||||
function handleRelink() {
|
||||
if (isDemo) return
|
||||
startTransition(async () => {
|
||||
const r = await relinkIdeaPlanAction(idea.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success('Idee terug naar PLAN_READY — open de Plan-tab.')
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2Off className="size-4 text-status-blocked" />
|
||||
<p className="text-sm font-medium text-status-blocked">
|
||||
De gekoppelde PBI bestaat niet meer
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Klik om dit idee terug naar PLAN_READY te zetten en opnieuw te materialiseren.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRelink}
|
||||
disabled={isDemo || pending}
|
||||
>
|
||||
Plan opnieuw beschikbaar maken
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
286
components/ideas/idea-row-actions.tsx
Normal file
286
components/ideas/idea-row-actions.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
'use client'
|
||||
|
||||
// IdeaRowActions — Grill Me / Make Plan / Materialiseer / Archive / Open.
|
||||
// Disabled-rules per M12 T-508:
|
||||
//
|
||||
// Grill Me: niet in GRILLING|PLANNING; vereist product-met-repo +
|
||||
// connectedWorkers > 0
|
||||
// Make Plan: alleen in GRILLED|PLAN_FAILED|PLAN_READY (re-plan); idem
|
||||
// voorwaarden
|
||||
// Materialiseer: alleen in PLAN_READY (geen worker nodig — synchrone parser)
|
||||
// PLANNED: alle drie disabled, "Bekijk PBI" link
|
||||
// *_FAILED: "Probeer opnieuw" knop (= start-job opnieuw)
|
||||
//
|
||||
// Demo-tooltip om elke muteer-knop. connectedWorkers wordt gelezen uit
|
||||
// useSoloStore (M12 grill-keuze 16 — geen lift voor v1).
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Archive,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Flame,
|
||||
Layers,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import {
|
||||
startGrillJobAction,
|
||||
startMakePlanJobAction,
|
||||
materializeIdeaPlanAction,
|
||||
} from '@/actions/ideas'
|
||||
import type { IdeaDto } from '@/lib/idea-dto'
|
||||
|
||||
interface IdeaRowActionsProps {
|
||||
idea: IdeaDto
|
||||
isDemo: boolean
|
||||
onArchive: () => void
|
||||
}
|
||||
|
||||
export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) {
|
||||
const router = useRouter()
|
||||
const connectedWorkers = useSoloStore((s) => s.connectedWorkers)
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
const hasProductWithRepo = idea.product != null && idea.product.repo_url !== null
|
||||
const workerOk = connectedWorkers > 0
|
||||
const status = idea.status
|
||||
|
||||
// ---- Grill Me ----
|
||||
const grillBlockedReason = (() => {
|
||||
if (status === 'grilling' || status === 'planning') return 'Job loopt al'
|
||||
if (status === 'planned') return 'Idee is gepland — open de PBI'
|
||||
if (!hasProductWithRepo) return 'Idee heeft een product met repo nodig'
|
||||
if (!workerOk) return 'Geen Claude-worker actief'
|
||||
return null
|
||||
})()
|
||||
const grillEnabled = !grillBlockedReason && !isDemo && !pending
|
||||
|
||||
// ---- Make Plan ----
|
||||
const makePlanAllowedStates = ['grilled', 'plan_failed', 'plan_ready']
|
||||
const makePlanBlockedReason = (() => {
|
||||
if (!makePlanAllowedStates.includes(status)) {
|
||||
if (status === 'draft' || status === 'grill_failed') return 'Eerst grillen'
|
||||
if (status === 'grilling' || status === 'planning') return 'Job loopt al'
|
||||
if (status === 'planned') return 'Idee is gepland — open de PBI'
|
||||
return null
|
||||
}
|
||||
if (!hasProductWithRepo) return 'Idee heeft een product met repo nodig'
|
||||
if (!workerOk) return 'Geen Claude-worker actief'
|
||||
return null
|
||||
})()
|
||||
const makePlanEnabled = !makePlanBlockedReason && !isDemo && !pending
|
||||
|
||||
// ---- Materialiseer ----
|
||||
const materializeBlockedReason = (() => {
|
||||
if (status !== 'plan_ready') return 'Plan is niet klaar'
|
||||
return null
|
||||
})()
|
||||
const materializeEnabled = !materializeBlockedReason && !isDemo && !pending
|
||||
|
||||
// ---- Failed-states tonen "Probeer opnieuw" ----
|
||||
const isFailedState = status === 'grill_failed' || status === 'plan_failed'
|
||||
|
||||
function runStart(action: typeof startGrillJobAction | typeof startMakePlanJobAction) {
|
||||
startTransition(async () => {
|
||||
const r = await action(idea.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success('Job in de wachtrij — een worker pakt hem op.')
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
function handleMaterialize() {
|
||||
if (!confirm('Plan materialiseren? Dit maakt PBI + stories + taken aan.')) return
|
||||
startTransition(async () => {
|
||||
const r = await materializeIdeaPlanAction(idea.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success(`Gematerialiseerd als ${r.data?.pbi_code}`)
|
||||
// Navigeer naar de nieuwe PBI in de product-backlog
|
||||
if (r.data?.pbi_id && idea.product_id) {
|
||||
router.push(`/products/${idea.product_id}/backlog#pbi-${r.data.pbi_code}`)
|
||||
} else {
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PLANNED-state: kortere variant met "Bekijk PBI"-link
|
||||
if (status === 'planned' && idea.pbi && idea.product_id) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(`/products/${idea.product_id}/backlog#pbi-${idea.pbi!.code}`)
|
||||
}
|
||||
>
|
||||
Bekijk {idea.pbi.code}
|
||||
<ExternalLink className="ml-1 size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/ideas/${idea.id}`)}
|
||||
aria-label="Open idee"
|
||||
title="Open idee"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Grill Me */}
|
||||
<ActionButton
|
||||
label="Grill"
|
||||
icon={<Flame className="size-3.5" />}
|
||||
enabled={grillEnabled}
|
||||
blockedReason={grillBlockedReason}
|
||||
isDemo={isDemo}
|
||||
onClick={() => runStart(startGrillJobAction)}
|
||||
/>
|
||||
|
||||
{/* Make Plan */}
|
||||
<ActionButton
|
||||
label="Plan"
|
||||
icon={<Sparkles className="size-3.5" />}
|
||||
enabled={makePlanEnabled}
|
||||
blockedReason={makePlanBlockedReason}
|
||||
isDemo={isDemo}
|
||||
onClick={() => runStart(startMakePlanJobAction)}
|
||||
/>
|
||||
|
||||
{/* Materialiseer */}
|
||||
<ActionButton
|
||||
label="Maak PBI"
|
||||
icon={<Layers className="size-3.5" />}
|
||||
enabled={materializeEnabled}
|
||||
blockedReason={materializeBlockedReason}
|
||||
isDemo={isDemo}
|
||||
onClick={handleMaterialize}
|
||||
variant="default"
|
||||
/>
|
||||
|
||||
{/* Failed-states: kleine retry-shortcut */}
|
||||
{isFailedState && (
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isDemo || pending || !workerOk || !hasProductWithRepo}
|
||||
onClick={() =>
|
||||
runStart(
|
||||
status === 'grill_failed' ? startGrillJobAction : startMakePlanJobAction,
|
||||
)
|
||||
}
|
||||
title="Probeer opnieuw"
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
)}
|
||||
|
||||
{/* Open detail */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/ideas/${idea.id}`)}
|
||||
aria-label="Open idee"
|
||||
title="Open idee"
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Archive */}
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onArchive}
|
||||
disabled={isDemo || pending}
|
||||
aria-label="Archiveer idee"
|
||||
title="Archiveer"
|
||||
>
|
||||
<Archive className="size-4" />
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
enabled: boolean
|
||||
blockedReason: string | null
|
||||
isDemo: boolean
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
icon,
|
||||
enabled,
|
||||
blockedReason,
|
||||
isDemo,
|
||||
onClick,
|
||||
variant = 'outline',
|
||||
}: ActionButtonProps) {
|
||||
// Bij demo: DemoTooltip toont reden. Bij niet-demo + reden: gewone tooltip.
|
||||
if (isDemo) {
|
||||
return (
|
||||
<DemoTooltip show>
|
||||
<Button size="sm" variant={variant} disabled>
|
||||
{icon}
|
||||
<span className="ml-1">{label}</span>
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (!enabled && blockedReason) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<Button size="sm" variant={variant} disabled>
|
||||
{icon}
|
||||
<span className="ml-1">{label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{blockedReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="sm" variant={variant} onClick={onClick}>
|
||||
{icon}
|
||||
<span className="ml-1">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
163
components/ideas/idea-timeline.tsx
Normal file
163
components/ideas/idea-timeline.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use client'
|
||||
|
||||
// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion entries.
|
||||
// Server-component zou ook kunnen, maar we mounten dit binnen de client-side
|
||||
// detail-layout dus client is simpler (geen rsc-boundary doorbreken).
|
||||
//
|
||||
// Iconen + kleur per log-type voor snelle herkenning.
|
||||
|
||||
import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
RefreshCw,
|
||||
StickyNote,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { IdeaLogType } from '@prisma/client'
|
||||
|
||||
export interface TimelineLog {
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
metadata: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TimelineQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[] | null
|
||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
answer: string | null
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logs: TimelineLog[]
|
||||
questions: TimelineQuestion[]
|
||||
}
|
||||
|
||||
const LOG_ICON: Record<IdeaLogType, React.ReactNode> = {
|
||||
DECISION: <Lightbulb className="size-4" />,
|
||||
NOTE: <StickyNote className="size-4" />,
|
||||
GRILL_RESULT: <FileText className="size-4" />,
|
||||
PLAN_RESULT: <ClipboardList className="size-4" />,
|
||||
STATUS_CHANGE: <RefreshCw className="size-4" />,
|
||||
JOB_EVENT: <Wrench className="size-4" />,
|
||||
}
|
||||
|
||||
const LOG_LABEL: Record<IdeaLogType, string> = {
|
||||
DECISION: 'Beslissing',
|
||||
NOTE: 'Notitie',
|
||||
GRILL_RESULT: 'Grill-resultaat',
|
||||
PLAN_RESULT: 'Plan-resultaat',
|
||||
STATUS_CHANGE: 'Status',
|
||||
JOB_EVENT: 'Job-event',
|
||||
}
|
||||
|
||||
const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
|
||||
open: 'Open',
|
||||
answered: 'Beantwoord',
|
||||
cancelled: 'Geannuleerd',
|
||||
expired: 'Verlopen',
|
||||
}
|
||||
|
||||
export function IdeaTimeline({ logs, questions }: Props) {
|
||||
const merged = [
|
||||
...logs.map((l) => ({
|
||||
kind: 'log' as const,
|
||||
created_at: l.created_at,
|
||||
data: l,
|
||||
})),
|
||||
...questions.map((q) => ({
|
||||
kind: 'question' as const,
|
||||
created_at: q.created_at,
|
||||
data: q,
|
||||
})),
|
||||
].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||
|
||||
if (merged.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center italic">
|
||||
Nog geen activiteit op dit idee.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2">
|
||||
{merged.map((entry, i) => {
|
||||
const time = new Date(entry.created_at).toLocaleString()
|
||||
|
||||
if (entry.kind === 'log') {
|
||||
const type = entry.data.type as IdeaLogType
|
||||
return (
|
||||
<li key={`l-${entry.data.id}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground">
|
||||
{LOG_ICON[type] ?? <StickyNote className="size-4" />}
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">
|
||||
{LOG_LABEL[type] ?? type}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p>
|
||||
{entry.data.metadata != null &&
|
||||
typeof entry.data.metadata === 'object' &&
|
||||
Object.keys(entry.data.metadata as object).length > 0 ? (
|
||||
<details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">metadata</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
|
||||
{JSON.stringify(entry.data.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const q = entry.data
|
||||
return (
|
||||
<li key={`q-${q.id}-${i}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
|
||||
<HelpCircle className="size-4" />
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Vraag</span>
|
||||
<span>·</span>
|
||||
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm">{q.question}</p>
|
||||
{q.options && q.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{q.options.map((o, ii) => (
|
||||
<li key={ii}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{q.answer ? (
|
||||
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
|
||||
Antwoord
|
||||
</span>
|
||||
{q.answer}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,6 +28,10 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
|
|||
status: 'open',
|
||||
expires_at: { gt: new Date() },
|
||||
product_id: { in: productIds },
|
||||
// Skip idea-questions (story_id NULL): they have a separate
|
||||
// realtime channel and aren't shown in this product-scoped bell.
|
||||
// Narrowing happens in the flatMap below — Prisma 7 rejects
|
||||
// `story_id: { not: null }` at runtime.
|
||||
},
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
|
|
@ -44,7 +48,9 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
|
|||
},
|
||||
})
|
||||
|
||||
const initial: NotificationQuestion[] = openQuestions.map((q) => ({
|
||||
const initial: NotificationQuestion[] = openQuestions.flatMap((q) => {
|
||||
if (!q.story || q.story_id === null) return []
|
||||
return [{
|
||||
id: q.id,
|
||||
product_id: q.product_id,
|
||||
story_id: q.story_id,
|
||||
|
|
@ -56,7 +62,8 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
|
|||
options: Array.isArray(q.options) ? (q.options as string[]) : null,
|
||||
created_at: q.created_at.toISOString(),
|
||||
expires_at: q.expires_at.toISOString(),
|
||||
}))
|
||||
}]
|
||||
})
|
||||
|
||||
return <NotificationsRealtimeMount initial={initial} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export function NavBar({
|
|||
)
|
||||
: disabledSpan('Solo')}
|
||||
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||
{navLink('/ideas', 'Ideeën', pathname.startsWith('/ideas'))}
|
||||
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
archiveSelectedTodosAction,
|
||||
promoteTodoToPbiAction,
|
||||
promoteTodoToStoryAction,
|
||||
promoteTodoToIdeaAction,
|
||||
} from '@/actions/todos'
|
||||
|
||||
interface Todo {
|
||||
|
|
@ -233,6 +235,60 @@ function PromoteStoryDialog({
|
|||
)
|
||||
}
|
||||
|
||||
// --- Promote to Idea dialog (M12 T-514) ---
|
||||
// Geen extra inputs nodig — title/description komen uit de todo, en
|
||||
// promoteTodoToIdeaAction archiveert de todo automatisch.
|
||||
function PromoteIdeaDialog({
|
||||
todo,
|
||||
onClose,
|
||||
}: { todo: Todo; onClose: () => void }) {
|
||||
const router = useRouter()
|
||||
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [handleKey])
|
||||
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
function handleConfirm() {
|
||||
startTransition(async () => {
|
||||
const r = await promoteTodoToIdeaAction(todo.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success(`Idee aangemaakt (${r.idea_code})`)
|
||||
onClose()
|
||||
router.push(`/ideas/${r.idea_id}`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
|
||||
<h2 className="font-medium text-foreground">Promoveer naar Idee</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maak een nieuw idee van ‘<strong>{todo.title}</strong>’. De Todo wordt
|
||||
gearchiveerd; je kunt hem later terugvinden in de archief-filter.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Het idee start als <code>DRAFT</code>. Je kunt het daarna grillen, plannen, en
|
||||
materialiseren tot een PBI.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={pending}>
|
||||
{pending ? 'Bezig…' : 'Promoveren'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Detail card ---
|
||||
function TodoCard({
|
||||
mode,
|
||||
|
|
@ -243,6 +299,7 @@ function TodoCard({
|
|||
onSuccess,
|
||||
onPromotePbi,
|
||||
onPromoteStory,
|
||||
onPromoteIdea,
|
||||
}: {
|
||||
mode: 'idle' | 'create' | 'edit'
|
||||
activeTodo: Todo | null
|
||||
|
|
@ -252,6 +309,7 @@ function TodoCard({
|
|||
onSuccess: () => void
|
||||
onPromotePbi: (todo: Todo) => void
|
||||
onPromoteStory: (todo: Todo) => void
|
||||
onPromoteIdea: (todo: Todo) => void
|
||||
}) {
|
||||
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
|
||||
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
|
||||
|
|
@ -366,6 +424,9 @@ function TodoCard({
|
|||
<div className="flex items-center gap-2">
|
||||
{!isDemo && (
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteIdea(activeTodo)}>
|
||||
→ Idee
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
|
||||
→ PBI
|
||||
</Button>
|
||||
|
|
@ -393,6 +454,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
|||
const [mode, setMode] = useState<'idle' | 'create'>('idle')
|
||||
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
|
||||
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
|
||||
const [promoteIdea, setPromoteIdea] = useState<Todo | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (selectedProductId === 'all') return todos
|
||||
|
|
@ -608,6 +670,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
|||
onSuccess={handleCancel}
|
||||
onPromotePbi={setPromotePbi}
|
||||
onPromoteStory={setPromoteStory}
|
||||
onPromoteIdea={setPromoteIdea}
|
||||
/>
|
||||
|
||||
{promotePbi && (
|
||||
|
|
@ -616,6 +679,9 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
|||
{promoteStory && (
|
||||
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
|
||||
)}
|
||||
{promoteIdea && (
|
||||
<PromoteIdeaDialog todo={promoteIdea} onClose={() => setPromoteIdea(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|
|||
|---|---|---|
|
||||
| [AnswerModal Profiel](./specs/dialogs/answer-modal.md) | active | 2026-05-04 |
|
||||
| [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.md) | active | 2026-05-04 |
|
||||
| [IdeaDialog Profiel](./specs/dialogs/idea.md) | active | 2026-05-04 |
|
||||
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
|
||||
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
|
||||
| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 |
|
||||
|
|
@ -43,6 +44,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|
|||
| [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 |
|
||||
| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 |
|
||||
| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 |
|
||||
| [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — |
|
||||
| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 |
|
||||
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
|
||||
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
|
|||
| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 |
|
||||
| M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 – ST-1008 |
|
||||
| M11: Claude vraagt, gebruiker antwoordt | Persistent vraag-antwoord-kanaal tussen Claude (MCP) en de actieve gebruiker | ST-1101 – ST-1108 |
|
||||
| M12: Ideeën & Grill/Plan jobs | Idee-entity tussen Todo en PBI; interactief grillen + deterministisch materialiseren | ST-1192 – ST-1201 |
|
||||
---
|
||||
|
||||
## Backlog
|
||||
|
|
@ -755,6 +756,49 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru
|
|||
|
||||
---
|
||||
|
||||
### M12: Ideeën & Grill/Plan jobs
|
||||
|
||||
**Implementatieplan:** [docs/plans/M12-ideas.md](../plans/M12-ideas.md)
|
||||
**Dialog-profiel:** [docs/specs/dialogs/idea.md](../specs/dialogs/idea.md)
|
||||
|
||||
Idee is een nieuw concept tussen Todo en PBI. Strikt user_id-only (privé), met
|
||||
twee Claude-jobs: **Grill Me** (interactief vragen-stellen via MCP) en **Make
|
||||
Plan** (single-pass yaml-frontmatter genereren). De **Materialiseer**-knop
|
||||
parseert het plan deterministisch en creëert PBI + stories + taken.
|
||||
|
||||
- [x] **ST-1192** — DB-schema & migratie voor Idea (T-491, T-492, T-489)
|
||||
- Idea-model + IdeaLog-model + 3 enums; ClaudeJob.task_id nullable + idea_id +
|
||||
kind; ClaudeQuestion.story_id nullable + idea_id; check-constraints +
|
||||
pg_notify-trigger update
|
||||
- [x] **ST-1193** — Lib + schemas + embedded prompts (T-493, T-494, T-495)
|
||||
- zod-schemas, status-mapper + transition-guard, atomic code-generator,
|
||||
yaml-frontmatter parser, embedded grill+make-plan prompts
|
||||
- [x] **ST-1194** — Server actions + Todo→Idea promotie (T-496..T-499)
|
||||
- CRUD, md-edit, job-triggers, materialize, relink, promoteTodoToIdeaAction
|
||||
- [x] **ST-1195** — REST API + proxy demo-laag (T-500, T-501)
|
||||
- /api/ideas + /api/ideas/[id]; demo-403 via proxy.ts catch-all
|
||||
- [x] **ST-1196** — Realtime SSE + idea-store (T-502, T-503)
|
||||
- SSE-routing voor idea-events; Zustand idea-store; extension van bestaande
|
||||
notifications-realtime hook
|
||||
- [ ] **ST-1197** — MCP-server tools (extern: madhura68/scrum4me-mcp)
|
||||
- get_idea_context, update_idea_grill_md, update_idea_plan_md, log_idea_decision;
|
||||
uitbreiding ask_user_question/wait_for_job/update_job_status; Docker rebuild
|
||||
- [x] **ST-1198** — UI lijstpagina + row-actions (T-507, T-508, T-509)
|
||||
- /ideas pagina, IdeaList tabel met filters, IdeaRowActions met
|
||||
disabled-rules per status, idea-status-badge helper
|
||||
- [x] **ST-1199** — UI detail + dialog + tabs (T-510..T-513)
|
||||
- /ideas/[id] met 4 tabs (Idee/Grill/Plan/Timeline); md-editor met
|
||||
yaml-validate; timeline met UNION view; pbi-link-card; dialog-profiel doc
|
||||
- [x] **ST-1200** — Promote-from-Todo + sidebar (T-514, T-515)
|
||||
- "→ Idee" knop in TodoCard, PromoteIdeaDialog, "Ideeën" nav-entry
|
||||
- [ ] **ST-1201** — End-to-end smoke + docs-update (T-516, T-517)
|
||||
- Volledige flow doorlopen volgens M12-ideas.md verificatie-script;
|
||||
docs/runbooks/mcp-integration.md uitbreiden voor IDEA_*-job-kinds
|
||||
- Done when: docs/INDEX.md opnieuw gegenereerd, alle stories ✓, MCP-server
|
||||
PR met passende versie-bump gedeployed
|
||||
|
||||
---
|
||||
|
||||
## v2 Backlog (na MVP)
|
||||
|
||||
- [ ] Uitnodigingsflow voor teams — e-mailuitnodiging of link-gebaseerd; nu kunnen alleen admins met toegang tot het systeem Developers toevoegen via gebruikersnaam
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 667 KiB |
299
docs/plans/M12-ideas.md
Normal file
299
docs/plans/M12-ideas.md
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
---
|
||||
title: "M12 — Idea entity + Grill/Plan Claude jobs"
|
||||
status: planned
|
||||
audience: implementation
|
||||
language: nl
|
||||
---
|
||||
|
||||
# M12 — Idea entity + Grill/Plan Claude jobs
|
||||
|
||||
## Context
|
||||
|
||||
Scrum4Me ondersteunt `Todo` als lichtgewicht voorstel-laag, en kan dat handmatig promoveren naar PBI/Story. Dat slaat het *denkproces* niet vast: waarom werd iets een PBI, welke alternatieven zijn afgewogen, welke randvoorwaarden waren er.
|
||||
|
||||
Doel: een nieuw concept **Idee** dat:
|
||||
- werkt als een Todo (top-level lijst, privé per gebruiker), met een **Grill Me**- en **Make Plan**-knop;
|
||||
- via de bestaande Claude-job/worker-infrastructuur een gestructureerd plan oplevert;
|
||||
- het hele planningsproces vastlegt (Q&A, beslissingen, grill-md, plan-md, link naar PBI);
|
||||
- na goedkeuring deterministisch materialiseert tot PBI + stories + taken (incl. `implementation_plan`).
|
||||
|
||||
## Vastgelegde keuzes (uit grill-sessie)
|
||||
|
||||
1. **UI-plek**: top-level `/ideas`, naast `/todos`.
|
||||
2. **Auth-scope**: strikt `user_id`-only (privé, ook ná `PLANNED`). Geen `productAccessFilter` op idea-acties; geen `pbi.idea_id`-veld nodig.
|
||||
3. **Product-binding**: een idee mag bestaan zonder product, maar **Grill Me** én **Make Plan** vereisen een product met `repo_url` (de worker leest sources/docs uit de repo). `claude_jobs.product_id` blijft NOT NULL.
|
||||
4. **Executie-model**: bestaand worker-model. `ClaudeJob{kind:IDEA_*}` QUEUED → lokale Claude-CLI claimt via `wait_for_job`. Knoppen zijn **disabled als `connectedWorkers === 0`** (exact zoals `solo/task-detail-dialog.tsx`).
|
||||
5. **Skill-afhankelijkheid**: **embedded prompts** in `lib/idea-prompts/{grill,make-plan}.md`; meegestuurd in payload. Geen externe `anthropic-skills:grill-me`-plugin-vereiste op de worker.
|
||||
6. **Make-Plan flow**: preview-en-bevestigen. Job produceert `Idea.plan_md`, status → `PLAN_READY`. Aparte knop **"Materialiseer plan"** parseert md → entiteiten in één Prisma-transactie, status → `PLANNED`.
|
||||
7. **Plan-md formaat**: YAML-frontmatter (structuur) + markdown-body (overwegingen, alternatieven, vrije reasoning).
|
||||
8. **Make-Plan-job**: single-pass (geen `ask_user_question`). Twijfels → terug naar grill (append-context).
|
||||
9. **Backward transitions**:
|
||||
- Re-grill vanuit `GRILLED`/`PLAN_READY`: nieuwe `IDEA_GRILL`-job met **append-context** (oude `grill_md` als input); oude versie naar `IdeaLog{type:GRILL_RESULT}` als history.
|
||||
- Re-plan vanuit `PLAN_READY`: idem voor `plan_md`.
|
||||
- PBI-verwijdering vanuit `PLANNED`: **expliciete user-actie "Re-link plan"** (geen DB-trigger). Zet `pbi_id=null`, status `PLAN_READY`.
|
||||
- Failed grill/plan: dedicated states **`GRILL_FAILED` / `PLAN_FAILED`** (zichtbaar voor user), niet stilzwijgend resetten.
|
||||
10. **Logging-model**: `IdeaLog` smal (`DECISION | NOTE | GRILL_RESULT | PLAN_RESULT | STATUS_CHANGE | JOB_EVENT`). Q&A blijft uitsluitend in `claude_questions`. Timeline-tab in UI doet `UNION ALL` over beide bronnen.
|
||||
11. **Opslag md-bestanden**: alleen DB (`Idea.grill_md`, `Idea.plan_md`). Geen auto-commit naar repo (zou strict-private auth-keuze ondergraven). UI biedt **"Download .md"**.
|
||||
12. **Editability**: beide md's bewerkbaar door user in hun ready-states (`GRILLED` voor grill_md, `PLAN_READY` voor plan_md). Bij `PLANNED`: read-only. Yaml-frontmatter wordt zod-gevalideerd-on-save voor `plan_md`.
|
||||
13. **Promotie vanuit Todo**: nieuwe `promoteTodoToIdeaAction` (Todo → DRAFT-Idea + Todo wordt `archived=true`). Bestaande Todo→PBI/Story-acties blijven onaangetast.
|
||||
14. **Demo-policy** (3-laag, zoals Todo): create/edit/archive **mag**; Grill / Make Plan / Materialiseer / promote-from-Todo zijn **geblokkeerd** (proxy.ts 403 + `session.isDemo`-guard + `<DemoTooltip>`).
|
||||
15. **Idea-code**: `Idea.code = "IDEA-{nnn}"`, `@@unique([user_id, code])`, counter op `User.idea_code_counter`.
|
||||
16. **Realtime-store**: nieuwe `stores/idea-store.ts`. `connectedWorkers` direct selecten via `useSoloStore(s => s.connectedWorkers)` (lift naar shared store is opvolg-refactor).
|
||||
17. **Sidebar**: nieuwe entry **Ideeën** (`Lightbulb`-icon) direct boven Todo's.
|
||||
18. **Q&A-expiry**: 24h aanhouden (consistent met bestaand). Verlopen → re-grill (append-context).
|
||||
|
||||
## State machine
|
||||
|
||||
```
|
||||
┌──── re-grill ────┐
|
||||
▼ │
|
||||
DRAFT ──Grill Me──▶ GRILLING ─done──▶ GRILLED ─Make Plan─▶ PLANNING ─done──▶ PLAN_READY ─Materialiseer─▶ PLANNED
|
||||
│ fail │ fail │ ▲ │
|
||||
▼ ▼ │ │ │
|
||||
GRILL_FAILED PLAN_FAILED └──┘ re-plan │
|
||||
│ │
|
||||
└────── retry/edit ──────────────────────────────────────── PBI verwijderd ──────────┘
|
||||
+ "Re-link plan"
|
||||
```
|
||||
|
||||
`archived: boolean` is orthogonaal en kan vanuit elke status.
|
||||
|
||||
## Datamodel
|
||||
|
||||
### Nieuwe enums
|
||||
```prisma
|
||||
enum IdeaStatus {
|
||||
DRAFT
|
||||
GRILLING
|
||||
GRILL_FAILED
|
||||
GRILLED
|
||||
PLANNING
|
||||
PLAN_FAILED
|
||||
PLAN_READY
|
||||
PLANNED
|
||||
}
|
||||
|
||||
enum ClaudeJobKind {
|
||||
TASK_IMPLEMENTATION
|
||||
IDEA_GRILL
|
||||
IDEA_MAKE_PLAN
|
||||
}
|
||||
|
||||
enum IdeaLogType {
|
||||
DECISION
|
||||
NOTE
|
||||
GRILL_RESULT
|
||||
PLAN_RESULT
|
||||
STATUS_CHANGE
|
||||
JOB_EVENT
|
||||
}
|
||||
```
|
||||
|
||||
### `User`
|
||||
- Veld toevoegen: `idea_code_counter Int @default(0)`.
|
||||
|
||||
### Nieuwe tabel `ideas`
|
||||
```prisma
|
||||
model Idea {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
||||
product_id String?
|
||||
code String @db.VarChar(30)
|
||||
title String
|
||||
description String? @db.VarChar(4000)
|
||||
grill_md String? @db.Text
|
||||
plan_md String? @db.Text
|
||||
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
|
||||
pbi_id String? @unique
|
||||
status IdeaStatus @default(DRAFT)
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
questions ClaudeQuestion[]
|
||||
jobs ClaudeJob[]
|
||||
logs IdeaLog[]
|
||||
|
||||
@@unique([user_id, code])
|
||||
@@index([user_id, archived, status])
|
||||
@@index([user_id, product_id])
|
||||
@@map("ideas")
|
||||
}
|
||||
```
|
||||
|
||||
### Nieuwe tabel `idea_logs`
|
||||
```prisma
|
||||
model IdeaLog {
|
||||
id String @id @default(cuid())
|
||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
idea_id String
|
||||
type IdeaLogType
|
||||
content String @db.Text
|
||||
metadata Json?
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@index([idea_id, created_at])
|
||||
@@map("idea_logs")
|
||||
}
|
||||
```
|
||||
|
||||
### Aanpassingen `claude_jobs`
|
||||
- `task_id` → **nullable**.
|
||||
- `idea_id String?` toegevoegd, FK → `Idea`, `onDelete: Cascade`.
|
||||
- `kind ClaudeJobKind @default(TASK_IMPLEMENTATION)` toegevoegd.
|
||||
- `product_id` blijft NOT NULL.
|
||||
- Raw-SQL check-constraint: `(task_id IS NOT NULL) <> (idea_id IS NOT NULL)`.
|
||||
- Index: `@@index([idea_id, status])`.
|
||||
|
||||
### Aanpassingen `claude_questions`
|
||||
- `story_id` → **nullable**.
|
||||
- `idea_id String?` toegevoegd, FK → `Idea`, `onDelete: Cascade`.
|
||||
- Raw-SQL check-constraint: `(story_id IS NOT NULL) <> (idea_id IS NOT NULL)`.
|
||||
- Index: `@@index([idea_id, status])`.
|
||||
- pg_notify-trigger payload uitbreiden met `idea_id` (nullable). SSE-filter laat idea-payloads alleen door naar `idea.user_id === session.user_id`.
|
||||
|
||||
## Server-laag
|
||||
|
||||
### Schemas + helpers
|
||||
- `lib/schemas/idea.ts` — `ideaCreateSchema`, `ideaUpdateSchema`, `ideaPlanMdFrontmatterSchema`.
|
||||
- `lib/idea-status.ts` — DB-enum ↔ API-string mapping.
|
||||
- `lib/idea-plan-parser.ts` — synchroon: `parsePlanMd(md): ParsedPlan | ZodError`. Gebruikt `yaml`-package + zod.
|
||||
- `lib/idea-code.ts` — atomair `nextIdeaCode(userId)` via Prisma-transactie.
|
||||
|
||||
### Embedded prompts
|
||||
- `lib/idea-prompts/grill.md` — eigen scrum4me-versie. Instrueert: gebruik `ask_user_question` MCP, schrijf via `update_idea_grill_md` aan eind.
|
||||
- `lib/idea-prompts/make-plan.md` — strict yaml-frontmatter-format. Instrueert: lees `grill_md`, gebruik repo-files, **geen vragen**, eindig met `update_idea_plan_md`.
|
||||
|
||||
### Server actions — `actions/ideas.ts`
|
||||
Volg `docs/patterns/server-action.md`: auth → demo-check → zod → user-id-scope-check → write → `revalidatePath`.
|
||||
|
||||
- `createIdeaAction(input)` — `nextIdeaCode(userId)`, status `DRAFT`.
|
||||
- `updateIdeaAction(id, input)` — alleen `DRAFT|GRILL_FAILED|GRILLED|PLAN_FAILED|PLAN_READY`.
|
||||
- `archiveIdeaAction(id)` / `unarchiveIdeaAction(id)`.
|
||||
- `deleteIdeaAction(id)` — geweigerd als `pbi_id` gevuld.
|
||||
- `updateGrillMdAction(id, md)` — alleen in `GRILLED|PLAN_READY`. Logt `IdeaLog{NOTE}`.
|
||||
- `updatePlanMdAction(id, md)` — alleen in `PLAN_READY`. Eerst `parsePlanMd(md)`; bij parse-fail → 422 met line-info.
|
||||
- `startGrillJobAction(id)` — vereist product met `repo_url`, `connectedWorkers > 0`. `ClaudeJob{kind:IDEA_GRILL, idea_id, product_id, QUEUED}`. Status → `GRILLING`. Demo: 403.
|
||||
- `startMakePlanJobAction(id)` — vereist `GRILLED|PLAN_FAILED|PLAN_READY` voor re-plan, product met repo, worker. Status → `PLANNING`. Demo: 403.
|
||||
- `cancelIdeaJobAction(id)` — actieve job CANCELLED, idea-status terug naar vorige.
|
||||
- `materializeIdeaPlanAction(id)` — `PLAN_READY` → `parsePlanMd` → Prisma-`$transaction`:
|
||||
1. Counters incrementeren (PBI/Story/Task).
|
||||
2. INSERT PBI + N stories + M tasks (incl. `implementation_plan`).
|
||||
3. UPDATE idea: `pbi_id`, `status:PLANNED`.
|
||||
4. INSERT `IdeaLog{type:PLAN_RESULT, metadata}`.
|
||||
Rollback bij ANY fail. Demo: 403.
|
||||
- `relinkIdeaPlanAction(id)` — alleen als `status===PLANNED && pbi_id===null`. Status → `PLAN_READY`.
|
||||
- `downloadIdeaMdAction(id, kind: 'grill'|'plan')` — server returnt md.
|
||||
|
||||
### Promote van Todo → Idea
|
||||
- `actions/todos.ts`: nieuwe `promoteTodoToIdeaAction(todoId)` — auth + demo + scope. Maakt Idea (DRAFT) met title/description; zet Todo `archived=true`. Demo: 403.
|
||||
|
||||
### REST-routes
|
||||
- `app/api/ideas/route.ts` (GET, POST) en `app/api/ideas/[id]/route.ts` (GET, PATCH).
|
||||
|
||||
### proxy.ts (demo-laag)
|
||||
- 403 op `POST/PATCH/DELETE /api/ideas*` voor demo-token.
|
||||
- 403 op grill/make-plan/materialize-endpoints.
|
||||
|
||||
## MCP-laag (`scrum4me-mcp`-repo)
|
||||
|
||||
### Nieuwe tools
|
||||
- `get_idea_context(idea_id)` — `{idea, product, repo_url, grill_md_so_far, open_questions, prompt_text}`.
|
||||
- `update_idea_grill_md(idea_id, markdown)` — schrijft veld; status → `GRILLED`; logt `IdeaLog{GRILL_RESULT}`.
|
||||
- `update_idea_plan_md(idea_id, markdown)` — schrijft veld; parser draait server-side; status → `PLAN_READY` of `PLAN_FAILED`.
|
||||
- `log_idea_decision(idea_id, type, content, metadata?)` — types: `DECISION | NOTE`.
|
||||
|
||||
### Uitbreiding bestaande tools
|
||||
- `ask_user_question`: contract uitbreiden — exact één van `story_id` of `idea_id`.
|
||||
- `wait_for_job`: response uitbreiden:
|
||||
- `kind: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'`
|
||||
- bij `IDEA_*`: `idea`, `product`, `repo_url`, `prompt_text`.
|
||||
- `update_job_status`: bij `failed` voor IDEA_*-jobs zet idea-status `GRILL_FAILED` / `PLAN_FAILED`.
|
||||
|
||||
### Schema-drift
|
||||
- `docs/runbooks/mcp-integration.md:62`: schema-drift-watchdog moet groen zijn vóór merge. MCP-server-PR parallel.
|
||||
|
||||
## Realtime-laag
|
||||
|
||||
- `app/api/realtime/notifications/route.ts` — idea-questions alleen aan `idea.user_id === session.user_id`.
|
||||
- `app/api/realtime/solo/route.ts` — `JobPayload` uitbreiden met `kind` en `idea_id`. Idea-jobs op `user_id`.
|
||||
- `stores/idea-store.ts` (nieuw). `connectedWorkers` direct uit `useSoloStore`.
|
||||
|
||||
## UI-laag
|
||||
|
||||
### Routing
|
||||
- `app/(app)/ideas/page.tsx` — top-level lijst.
|
||||
- `app/(app)/ideas/[id]/page.tsx` — detailpagina met tabs **Idee** · **Grill** · **Plan** · **Timeline**.
|
||||
- Sidebar-entry: `Lightbulb`, label "Ideeën", boven Todo's.
|
||||
|
||||
### Componenten — `components/ideas/`
|
||||
- `idea-list.tsx` — TanStack Table; kolommen code/title/product/status/archived. Bulk-archive.
|
||||
- `idea-row-actions.tsx` — Grill Me / Make Plan / Materialiseer / Edit / Archive met disabled-rules.
|
||||
- `idea-dialog.tsx` + `components/dialogs/idea-dialog.tsx` (wrapper) volgens dialog-pattern.
|
||||
- `idea-md-editor.tsx` — markdown editor met yaml-validate voor plan_md.
|
||||
- `idea-timeline.tsx` — UNION-view IdeaLog + claude_questions.
|
||||
- `idea-pbi-link-card.tsx` — incl. "Re-link plan"-banner.
|
||||
- `download-md-button.tsx`.
|
||||
|
||||
### Promote-from-Todo UI
|
||||
- `components/todos/todo-list.tsx`: extra menu-item "Promote naar Idee".
|
||||
|
||||
### Profiel-doc
|
||||
- `docs/specs/dialogs/idea.md` — verplicht volgens dialog-pattern.
|
||||
|
||||
## Te raken / aan te maken bestanden
|
||||
|
||||
| Laag | Bestand |
|
||||
|---|---|
|
||||
| Schema | `prisma/schema.prisma` |
|
||||
| Migratie | `prisma/migrations/<ts>_add_ideas/migration.sql` |
|
||||
| Schemas | `lib/schemas/idea.ts`, `lib/idea-status.ts`, `lib/idea-plan-parser.ts`, `lib/idea-code.ts` |
|
||||
| Prompts | `lib/idea-prompts/grill.md`, `lib/idea-prompts/make-plan.md` |
|
||||
| Actions | `actions/ideas.ts`, uitbreiding `actions/todos.ts` |
|
||||
| API | `app/api/ideas/route.ts`, `app/api/ideas/[id]/route.ts`, `proxy.ts` |
|
||||
| Realtime | `app/api/realtime/notifications/route.ts`, `app/api/realtime/solo/route.ts` |
|
||||
| Pages | `app/(app)/ideas/page.tsx`, `app/(app)/ideas/[id]/page.tsx` |
|
||||
| UI | `components/ideas/*.tsx`, `components/dialogs/idea-dialog.tsx`, sidebar-update |
|
||||
| Store | `stores/idea-store.ts` |
|
||||
| Docs | `docs/specs/dialogs/idea.md`, `docs/runbooks/mcp-integration.md`, `docs/backlog/index.md` |
|
||||
| MCP-server | `madhura68/scrum4me-mcp` (parallel-PR) |
|
||||
|
||||
## Implementatievolgorde
|
||||
|
||||
1. **DB & migratie**
|
||||
2. **Lib + schemas + prompts**
|
||||
3. **Server actions + Todo-promote**
|
||||
4. **REST + proxy demo-laag**
|
||||
5. **Realtime SSE + idea-store**
|
||||
6. **MCP-server tools (extern repo, parallel)**
|
||||
7. **UI lijst + row-actions**
|
||||
8. **UI detail + dialog + tabs**
|
||||
9. **UI promote-from-Todo + sidebar-entry**
|
||||
10. **End-to-end smoke + docs**
|
||||
|
||||
## Verificatie
|
||||
|
||||
```bash
|
||||
npm run lint && npm test && npm run build
|
||||
```
|
||||
|
||||
End-to-end:
|
||||
1. `npm run dev` + lokale Claude-CLI met `wait_for_job`-loop.
|
||||
2. Maak idee, koppel aan product. Status `DRAFT`.
|
||||
3. Grill Me → vragen via answer-modal → `update_idea_grill_md` → `GRILLED`.
|
||||
4. Edit grill_md handmatig → `IdeaLog{NOTE}`.
|
||||
5. Make Plan → `update_idea_plan_md` → `PLAN_READY`.
|
||||
6. Yaml-fout in plan_md → save geblokkeerd.
|
||||
7. Materialiseer → PBI + stories + taken in transactie. `idea.pbi_id` gezet, `PLANNED`.
|
||||
8. PBI verwijderen → "Re-link plan"-banner → `PLAN_READY`.
|
||||
9. Demo-test: knoppen geblokkeerd via DemoTooltip + 403.
|
||||
10. Failure-test: kill worker → `GRILL_FAILED`/`PLAN_FAILED`.
|
||||
11. Promote-test: Todo → "Promote naar Idee" → DRAFT-Idea, Todo archived.
|
||||
|
||||
## Open punten (niet-blokkerend)
|
||||
|
||||
- Concrete copy-finetuning van prompt-md's tijdens implementatie.
|
||||
- Lift `connectedWorkers` naar gedeelde `worker-presence-store` (opvolg-refactor).
|
||||
- Optionele "Commit plan_md naar repo"-knop (buiten v1).
|
||||
|
|
@ -35,15 +35,35 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
|
|||
- `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag
|
||||
|
||||
**Job queue — agent worker mode (M13):**
|
||||
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg.
|
||||
- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`.
|
||||
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator:
|
||||
- `kind: 'TASK_IMPLEMENTATION'` (default) — payload met `implementation_plan`, `story`, `pbi`, `sprint`, `repo_url`
|
||||
- `kind: 'IDEA_GRILL'` of `'IDEA_MAKE_PLAN'` — payload met `idea`, `product`, `repo_url`, en `prompt_text` (de embedded prompt uit `lib/idea-prompts/`)
|
||||
Stale CLAIMED-jobs (>30min) worden eerst terug naar QUEUED gezet. Lege queue na block-time = klaar.
|
||||
- `mcp__scrum4me__update_job_status` — agent rapporteert `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event. Bij `failed` voor `IDEA_GRILL`/`IDEA_MAKE_PLAN` wordt de idea-status automatisch op `GRILL_FAILED` resp. `PLAN_FAILED` gezet. Auth: Bearer-token moet matchen `claimed_by_token_id`.
|
||||
|
||||
**Idea-jobs (M12) — agent gedrag per kind:**
|
||||
|
||||
| Kind | Werkwijze | Eind-call |
|
||||
|---|---|---|
|
||||
| `IDEA_GRILL` | Lees `prompt_text` (embedded grill-prompt) + `idea.grill_md` als startpunt; itereer met `ask_user_question(idea_id=...)`/`get_question_answer`; log onderweg `log_idea_decision`; eindig met `update_idea_grill_md(markdown)` | `update_job_status('done')` |
|
||||
| `IDEA_MAKE_PLAN` | Lees `prompt_text` (embedded make-plan-prompt) + `idea.grill_md` + repo-context. **Stel GEEN vragen** — single-pass output. Bouw plan in strict yaml-frontmatter format en eindig met `update_idea_plan_md(markdown)`. Server-side parser kan parse-fail → `PLAN_FAILED` | `update_job_status('done')` |
|
||||
|
||||
**MCP-tools — Idea-laag (M12):**
|
||||
- `mcp__scrum4me__get_idea_context(idea_id)` — `{ idea, product, repo_url, grill_md_so_far, open_questions, prompt_text }`
|
||||
- `mcp__scrum4me__update_idea_grill_md(idea_id, markdown)` — schrijft veld; status → `GRILLED`; logt `IdeaLog{GRILL_RESULT}`
|
||||
- `mcp__scrum4me__update_idea_plan_md(idea_id, markdown)` — server-side `parsePlanMd`; ok → `PLAN_READY` + `IdeaLog{PLAN_RESULT}`; parse-fail → `PLAN_FAILED` + `IdeaLog{JOB_EVENT, errors}`
|
||||
- `mcp__scrum4me__log_idea_decision(idea_id, type, content, metadata?)` — `type ∈ {DECISION, NOTE}`
|
||||
- `mcp__scrum4me__ask_user_question` — geüpgrade contract: exact één van `story_id` óf `idea_id` (xor); idea-vragen zijn user-private (geen productAccessFilter).
|
||||
|
||||
## Batch-loop (verplichte agent-flow)
|
||||
|
||||
Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop:
|
||||
|
||||
1. `wait_for_job` aanroepen.
|
||||
2. Job uitvoeren volgens het meegegeven `implementation_plan`.
|
||||
2. Switch op `kind`:
|
||||
- `TASK_IMPLEMENTATION`: voer uit volgens het meegegeven `implementation_plan` (zoals altijd — branch, code, commit, push, verify_task_against_plan).
|
||||
- `IDEA_GRILL`: laad `prompt_text` als gids; gebruik `ask_user_question` / `get_question_answer` voor de Q&A-loop; eindig met `update_idea_grill_md`.
|
||||
- `IDEA_MAKE_PLAN`: laad `prompt_text` + `idea.grill_md`; **stel geen vragen**; produceer strict yaml-frontmatter; eindig met `update_idea_plan_md`.
|
||||
3. `update_job_status('done'|'failed')` aanroepen.
|
||||
4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen.
|
||||
5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap.
|
||||
|
|
|
|||
167
docs/specs/dialogs/idea.md
Normal file
167
docs/specs/dialogs/idea.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
---
|
||||
title: "IdeaDialog Profiel"
|
||||
status: active
|
||||
audience: [ai-agent, contributor]
|
||||
language: nl
|
||||
last_updated: 2026-05-04
|
||||
---
|
||||
|
||||
# IdeaDialog / IdeaDetailLayout Profiel
|
||||
|
||||
> Volgt **`docs/patterns/dialog.md`** (de generieke spec voor élke entity-dialog in Scrum4Me).
|
||||
> Dit document beschrijft alleen de Idea-specifieke afwijkingen en keuzes — alle gedeelde regels (layout, motion, demo-policy, foutcodes, validatie, theming) staan in de generieke spec en worden hier niet herhaald.
|
||||
|
||||
> **Belangrijk:** als een regel in dit profiel botst met de generieke spec, wint de generieke spec. Documenteer hier de afwijking + reden, of pas de generieke spec aan.
|
||||
|
||||
---
|
||||
|
||||
## Velden
|
||||
|
||||
| Veld | Type | Mode | Validatie | Bron-zod |
|
||||
|---|---|---|---|---|
|
||||
| `title` | `string` (required) | beide | trim, 1-200 chars | `ideaCreateSchema.title` |
|
||||
| `description` | `string \| null` | beide | optional, max 4000 chars, plain textarea | `ideaCreateSchema.description` |
|
||||
| `product_id` | `string \| null` | beide | optional cuid; **vereist voordat Grill/Make Plan kan starten** (M12 grill-keuze 3) | `ideaCreateSchema.product_id` |
|
||||
| `code` | `string` (auto) | read-only | `IDEA-NNN`, server-generated via `nextIdeaCode(userId)` op `User.idea_code_counter` | n.v.t. |
|
||||
| `status` | `IdeaStatus` enum | read-only | door server gezet via state-machine | `lib/idea-status.ts canTransition` |
|
||||
| `grill_md` | `string \| null` | edit-tab | bewerkbaar in `GRILLED \| PLAN_READY` | n.v.t. |
|
||||
| `plan_md` | `string \| null` | edit-tab | bewerkbaar in `PLAN_READY` + yaml-frontmatter must parse | `ideaPlanMdFrontmatterSchema` |
|
||||
| `archived` | `boolean` | read-only | via archive-actie | n.v.t. |
|
||||
| `pbi_id` | `string \| null` | read-only | gezet door `materializeIdeaPlanAction`, `SetNull` als PBI verwijderd | n.v.t. |
|
||||
|
||||
---
|
||||
|
||||
## URL- of state-pattern
|
||||
|
||||
**Afwijking van generieke spec:** Idee gebruikt een **dedicated route** `/ideas/[id]` ipv een modal-dialog. Reden: het detail-scherm is rijker dan een modal kan dragen (4 tabs incl. md-editor + timeline) en de planningsgeschiedenis is een leesbaar artifact dat verdiend om bookmarkable te zijn.
|
||||
|
||||
- **Lijst-create**: state-based inline form bovenaan `/ideas` lijst (`IdeaList.showCreate`).
|
||||
- **Detail / edit**: route `/ideas/[id]` met tab-switcher via query-param (`?tab=idee|grill|plan|timeline`).
|
||||
- **Geen modal**: dus geen `Cmd/Ctrl+Enter`-submit op de detail-form (alleen op md-editor); `Esc` doet niets in het detail-scherm.
|
||||
|
||||
---
|
||||
|
||||
## Tabs (alleen op detail-route)
|
||||
|
||||
| Tab | Content | Editable in |
|
||||
|---|---|---|
|
||||
| `idee` | inline form (title, description, product_id) | `DRAFT \| GRILL_FAILED \| GRILLED \| PLAN_FAILED \| PLAN_READY` |
|
||||
| `grill` | `grill_md` markdown render + Bewerk-knop | `GRILLED \| PLAN_READY` |
|
||||
| `plan` | `plan_md` markdown render + Bewerk-knop | `PLAN_READY` |
|
||||
| `timeline` | UNION van `IdeaLog` + `ClaudeQuestion` chronologisch | n.v.t. (read-only) |
|
||||
|
||||
`isIdeaEditable`, `isGrillMdEditable` en `isPlanMdEditable` helpers in `lib/idea-status.ts` bepalen de exacte regels.
|
||||
|
||||
---
|
||||
|
||||
## Status-machine
|
||||
|
||||
```
|
||||
DRAFT ──Grill──▶ GRILLING ─done──▶ GRILLED ──Make Plan──▶ PLANNING ─done──▶ PLAN_READY ──Materialiseer──▶ PLANNED
|
||||
│ fail │ fail ▲ │
|
||||
▼ ▼ │ │
|
||||
GRILL_FAILED PLAN_FAILED └─── re-grill / re-plan (append-context) │
|
||||
│
|
||||
PLANNED ◀── (PBI verwijderd: pbi_id=null, status blijft PLANNED tot Re-link) ────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Van | Naar | Trigger | Server-action |
|
||||
|---|---|---|---|
|
||||
| `DRAFT` | `GRILLING` | "Grill" knop | `startGrillJobAction` |
|
||||
| `GRILLING` | `GRILLED` | worker → `update_idea_grill_md` | (MCP) |
|
||||
| `GRILLING` | `GRILL_FAILED` | worker → `update_job_status('failed')` | (MCP) |
|
||||
| `GRILLED` / `PLAN_FAILED` / `PLAN_READY` | `GRILLING` | "Grill" knop (re-grill) | `startGrillJobAction` |
|
||||
| `GRILLED` / `PLAN_FAILED` / `PLAN_READY` | `PLANNING` | "Plan" knop | `startMakePlanJobAction` |
|
||||
| `PLANNING` | `PLAN_READY` | worker → `update_idea_plan_md` (parser ok) | (MCP) |
|
||||
| `PLANNING` | `PLAN_FAILED` | worker → `update_job_status('failed')` of parse-fail | (MCP) |
|
||||
| `PLAN_READY` | `PLANNED` | "Maak PBI" knop | `materializeIdeaPlanAction` |
|
||||
| `PLANNED` (pbi_id=null) | `PLAN_READY` | "Plan opnieuw beschikbaar maken" knop | `relinkIdeaPlanAction` |
|
||||
| any | `*` archived | Archive-knop | `archiveIdeaAction` |
|
||||
|
||||
---
|
||||
|
||||
## Server actions
|
||||
|
||||
`actions/ideas.ts`:
|
||||
|
||||
| Actie | Precondition | Effect |
|
||||
|---|---|---|
|
||||
| `createIdeaAction(input)` | auth + niet-demo | nieuwe DRAFT-idea + auto-code |
|
||||
| `updateIdeaAction(id, input)` | `isIdeaEditable(status)` | update title/description/product_id |
|
||||
| `archiveIdeaAction(id)` / `unarchiveIdeaAction(id)` | scoped on user_id | flip `archived` |
|
||||
| `deleteIdeaAction(id)` | `pbi_id === null` | hard delete (cascades naar IdeaLog) |
|
||||
| `updateGrillMdAction(id, md)` | `isGrillMdEditable(status)` | update + IdeaLog{NOTE} |
|
||||
| `updatePlanMdAction(id, md)` | `isPlanMdEditable(status)` + `parsePlanMd.ok` | update + IdeaLog{NOTE} |
|
||||
| `startGrillJobAction(id)` | product+repo + worker actief + status in `GRILL_TRIGGERABLE_FROM` | enqueue ClaudeJob{kind:IDEA_GRILL} |
|
||||
| `startMakePlanJobAction(id)` | idem + status in `MAKE_PLAN_TRIGGERABLE_FROM` | enqueue ClaudeJob{kind:IDEA_MAKE_PLAN} |
|
||||
| `cancelIdeaJobAction(id)` | actieve job aanwezig | job→CANCELLED + status revert |
|
||||
| `materializeIdeaPlanAction(id)` | `status===PLAN_READY` + `plan_md` parseable | atomic create PBI + stories + tasks; idea→PLANNED |
|
||||
| `relinkIdeaPlanAction(id)` | `status===PLANNED && pbi_id===null` | status→PLAN_READY |
|
||||
| `downloadIdeaMdAction(id, kind)` | scope (demo OK, read-only) | return md-string |
|
||||
| `promoteTodoToIdeaAction(todoId)` (in `actions/todos.ts`) | todo niet archived + niet-demo | DRAFT-idea + Todo→archived |
|
||||
|
||||
Foutcodes: 400 = JSON parse, 401 = auth, 403 = demo, 404 = scope/not-found, 409 = idempotency/race, 422 = validatie/status-mismatch, 429 = rate-limit.
|
||||
|
||||
---
|
||||
|
||||
## Demo-policy (3-laag)
|
||||
|
||||
| Laag | Wat | Waar |
|
||||
|---|---|---|
|
||||
| 1 | `proxy.ts` blokt `POST/PATCH/DELETE /api/ideas*` | `proxy.ts` catch-all rule |
|
||||
| 2 | `session.isDemo` guard in elke muteer-actie | `actions/ideas.ts` |
|
||||
| 3 | `<DemoTooltip show={isDemo}>` rondom muteer-knoppen | `idea-row-actions.tsx`, `idea-list.tsx`, `idea-detail-layout.tsx`, `download-md-button.tsx` (NIET — read-only mag) |
|
||||
|
||||
Demo-user MAG: lijst zien, idee zien, navigeren tussen tabs, downloaden van md.
|
||||
Demo-user MAG NIET: aanmaken, bewerken, archiveren, Grill, Plan, Materialiseer, Re-link, Promote-from-Todo.
|
||||
|
||||
---
|
||||
|
||||
## Special behaviors
|
||||
|
||||
### IdeaMdEditor
|
||||
|
||||
- **Cmd/Ctrl+S** triggert save (alleen in editor, niet in detail-form).
|
||||
- **localStorage draft** per `(idea_id, kind)`: lazy read-on-mount via `useState(() => readSeed(...))` om setState-in-effect te vermijden. Drift met server → toast info bij restore.
|
||||
- **Live yaml-validate** voor plan-kind: `useMemo(() => parsePlanMd(value))` → derived state, geen useEffect.
|
||||
- **Submit-errors** los van validation-errors in state — server-side details overschrijven client-side validate als die er zijn.
|
||||
|
||||
### IdeaPbiLinkCard
|
||||
|
||||
- Drie states: PLANNED+pbi (groene link), PLANNED+pbi-null (oranje banner met Re-link knop), niet-PLANNED (return null).
|
||||
|
||||
### Status badges
|
||||
|
||||
- Status-tokens via `lib/idea-status-colors.ts` → `getIdeaStatusBadge(status)` → `{ label, classes, pulse? }`.
|
||||
- `GRILLING` en `PLANNING` → `animate-pulse` om "actief" te signaleren.
|
||||
|
||||
### Connected workers
|
||||
|
||||
- `IdeaRowActions` leest `useSoloStore(s => s.connectedWorkers)` (M12 grill-keuze 16 — geen lift naar gedeelde store voor v1).
|
||||
- Zonder worker: Grill / Make Plan disabled met tooltip "Geen Claude-worker actief". Materialiseer is server-side synchroon en heeft géén worker nodig.
|
||||
|
||||
---
|
||||
|
||||
## Realtime
|
||||
|
||||
SSE-stream `/api/realtime/notifications` levert idea-events (M12 T-502). Routing in `lib/realtime/use-notifications-realtime.ts`:
|
||||
|
||||
- `claude_job_*` payloads met `kind=IDEA_*` → `useIdeaStore.handleIdeaJobEvent`
|
||||
- `entity:'question'` payloads met `idea_id` set → `useIdeaStore.handleIdeaQuestionEvent`
|
||||
- Story-questions blijven in `useNotificationsStore`
|
||||
|
||||
`useIdeaStore` houdt optimistic state: `jobByIdea`, `ideaStatuses`, `openQuestionsByIdea`. Voor de detail-pagina is de server-state na `router.refresh()` source-of-truth — de store is een UI-cache.
|
||||
|
||||
---
|
||||
|
||||
## Test-fixtures
|
||||
|
||||
- `__tests__/actions/ideas-crud.test.ts` (39 cases) — alle CRUD + job-trigger + materialize + relink paden
|
||||
- `__tests__/api/ideas.test.ts` (13 cases) — REST-laag
|
||||
- `__tests__/stores/idea-store.test.ts` (7 cases) — Zustand event-handling
|
||||
- `__tests__/lib/idea-status.test.ts` (15+ cases) — status mappers + transition guards
|
||||
- `__tests__/lib/idea-schemas.test.ts` (16+ cases) — zod-validatie
|
||||
- `__tests__/lib/idea-plan-parser.test.ts` (6 cases) — yaml-frontmatter
|
||||
- `__tests__/proxy/demo-guard.test.ts` (9 cases) — incl. 3 idea-cases
|
||||
|
||||
Geen Playwright/MSW E2E voor v1 — handmatig E2E-script staat in `docs/plans/M12-ideas.md` "Verificatie".
|
||||
26
lib/idea-code-server.ts
Normal file
26
lib/idea-code-server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Atomic per-user idea-code generator (DB-side).
|
||||
// Schema: User.idea_code_counter Int @default(0) — increment-and-return via
|
||||
// Prisma `update` (which acquires a row-lock for the duration of the
|
||||
// transaction; concurrent calls serialize). Format: "IDEA-001", "IDEA-002", …
|
||||
//
|
||||
// Concurrency: vertrouwt op Postgres row-locking binnen Prisma `update`.
|
||||
// Geen aparte $transaction nodig voor enkelvoudige update — de update is
|
||||
// atomisch op één rij. Voor combineren met een idea.create wordt
|
||||
// nextIdeaCode aangeroepen binnen de bredere $transaction van de caller.
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { formatIdeaCode } from '@/lib/idea-code'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export async function nextIdeaCode(
|
||||
userId: string,
|
||||
client: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<string> {
|
||||
const u = await client.user.update({
|
||||
where: { id: userId },
|
||||
data: { idea_code_counter: { increment: 1 } },
|
||||
select: { idea_code_counter: true },
|
||||
})
|
||||
return formatIdeaCode(u.idea_code_counter)
|
||||
}
|
||||
8
lib/idea-code.ts
Normal file
8
lib/idea-code.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Pure helpers voor IDEA-codes. Geen DB-imports — daarom client-safe.
|
||||
// De DB-mutating nextIdeaCode staat in lib/idea-code-server.ts.
|
||||
|
||||
const PAD = 3 // "IDEA-001". Bumps to 4 digits at counter 1000 organically.
|
||||
|
||||
export function formatIdeaCode(n: number): string {
|
||||
return `IDEA-${String(n).padStart(PAD, '0')}`
|
||||
}
|
||||
49
lib/idea-dto.ts
Normal file
49
lib/idea-dto.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// API-projection voor Idea — converteert Prisma-row naar het externe contract.
|
||||
// Belangrijk: status wordt naar lowercase API-string vertaald (zelfde patroon
|
||||
// als TaskStatus / StoryStatus / PbiStatus elders in de codebase).
|
||||
|
||||
import { ideaStatusToApi } from '@/lib/idea-status'
|
||||
|
||||
import type { Idea, IdeaStatus, Product } from '@prisma/client'
|
||||
|
||||
type IdeaWithProduct = Idea & {
|
||||
product: Pick<Product, 'id' | 'name' | 'repo_url'> | null
|
||||
pbi?: { id: string; code: string; title: string } | null
|
||||
}
|
||||
|
||||
export interface IdeaDto {
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: ReturnType<typeof ideaStatusToApi>
|
||||
product_id: string | null
|
||||
product: { id: string; name: string; repo_url: string | null } | null
|
||||
pbi_id: string | null
|
||||
pbi?: { id: string; code: string; title: string } | null
|
||||
archived: boolean
|
||||
has_grill_md: boolean
|
||||
has_plan_md: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export function ideaToDto(idea: IdeaWithProduct & { status: IdeaStatus }): IdeaDto {
|
||||
return {
|
||||
id: idea.id,
|
||||
code: idea.code,
|
||||
title: idea.title,
|
||||
description: idea.description,
|
||||
status: ideaStatusToApi(idea.status),
|
||||
product_id: idea.product_id,
|
||||
product: idea.product,
|
||||
pbi_id: idea.pbi_id,
|
||||
pbi: idea.pbi ?? null,
|
||||
archived: idea.archived,
|
||||
// Geen md-content in lijst-payloads (kan groot zijn) — enkel een vlag.
|
||||
has_grill_md: idea.grill_md !== null,
|
||||
has_plan_md: idea.plan_md !== null,
|
||||
created_at: idea.created_at.toISOString(),
|
||||
updated_at: idea.updated_at.toISOString(),
|
||||
}
|
||||
}
|
||||
73
lib/idea-plan-parser.ts
Normal file
73
lib/idea-plan-parser.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// Parser voor de plan_md die make-plan-job produceert.
|
||||
// Format: yaml-frontmatter (structuur, parseerbaar) + markdown-body (vrije
|
||||
// reasoning). Frontmatter wordt gevalideerd via ideaPlanMdFrontmatterSchema.
|
||||
//
|
||||
// Wordt zowel door de server-action materializeIdeaPlanAction als door de
|
||||
// MCP-tool update_idea_plan_md gebruikt. Synchroon — geen LLM-call.
|
||||
//
|
||||
// Zie docs/plans/M12-ideas.md "Plan-md formaat A" voor het format-voorbeeld.
|
||||
|
||||
import { parse as parseYaml, YAMLParseError } from 'yaml'
|
||||
import {
|
||||
ideaPlanMdFrontmatterSchema,
|
||||
type IdeaPlanFrontmatter,
|
||||
} from '@/lib/schemas/idea'
|
||||
|
||||
export type PlanParseError = { line?: number; message: string }
|
||||
|
||||
export type PlanParseResult =
|
||||
| { ok: true; plan: IdeaPlanFrontmatter; body: string }
|
||||
| { ok: false; errors: PlanParseError[] }
|
||||
|
||||
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
|
||||
export function parsePlanMd(md: string): PlanParseResult {
|
||||
const match = md.match(FRONTMATTER_RE)
|
||||
if (!match) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
line: 1,
|
||||
message:
|
||||
'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const [, frontmatterRaw, body] = match
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = parseYaml(frontmatterRaw)
|
||||
} catch (err) {
|
||||
if (err instanceof YAMLParseError) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
line: err.linePos?.[0]?.line,
|
||||
message: err.message,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ message: err instanceof Error ? err.message : String(err) }],
|
||||
}
|
||||
}
|
||||
|
||||
const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed)
|
||||
if (!validation.success) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: validation.error.issues.map((iss) => ({
|
||||
message: `${iss.path.join('.') || '<root>'}: ${iss.message}`,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, plan: validation.data, body: body.trimStart() }
|
||||
}
|
||||
98
lib/idea-prompts/grill.md
Normal file
98
lib/idea-prompts/grill.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Grill-prompt voor IDEA_GRILL-jobs
|
||||
|
||||
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
|
||||
> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt
|
||||
> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill
|
||||
> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen
|
||||
> versie zodat de flow reproduceerbaar is op elke worker.
|
||||
|
||||
---
|
||||
|
||||
Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel:
|
||||
`{idea_title}`).
|
||||
|
||||
Je context (meegegeven in `wait_for_job`-payload):
|
||||
|
||||
- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md`
|
||||
- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`)
|
||||
- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al)
|
||||
|
||||
## Doel
|
||||
|
||||
Het idee zó concretiseren dat de **make-plan**-fase er een implementeerbaar
|
||||
PBI van kan maken. Eindresultaat is een markdown-document dat je via
|
||||
`mcp__scrum4me__update_idea_grill_md` opslaat.
|
||||
|
||||
## Werkwijze (loop, één vraag per cyclus)
|
||||
|
||||
1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig)
|
||||
`idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit
|
||||
het niet weg.
|
||||
2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante
|
||||
source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal.
|
||||
3. Stel **één scherpe vraag tegelijk** via
|
||||
`mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht
|
||||
op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`).
|
||||
4. Verwerk het antwoord: log belangrijke beslissingen via
|
||||
`mcp__scrum4me__log_idea_decision({ idea_id, type: 'DECISION'|'NOTE',
|
||||
content })`.
|
||||
5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie).
|
||||
6. Schrijf het eindresultaat via
|
||||
`mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`.
|
||||
7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`.
|
||||
|
||||
## Stop-conditie
|
||||
|
||||
Je hebt genoeg wanneer je markdown bevat:
|
||||
|
||||
- **Titel + scope** (1–3 zinnen)
|
||||
- **Minimaal 3 acceptatiepunten** (gedrag dat zichtbaar moet werken)
|
||||
- **Minimaal 1 risico/onbekende** (technisch, scope, afhankelijkheden)
|
||||
- **Open eindjes** (wat opzettelijk **niet** in v1 zit)
|
||||
|
||||
Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door".
|
||||
|
||||
## Output-format (strikt)
|
||||
|
||||
```markdown
|
||||
# Idee — {korte titel}
|
||||
|
||||
## Scope
|
||||
…
|
||||
|
||||
## Acceptatie
|
||||
- AC 1
|
||||
- AC 2
|
||||
- AC 3
|
||||
|
||||
## Risico's & onbekenden
|
||||
- Risico 1
|
||||
- Onbekende 2
|
||||
|
||||
## Open eindjes (niet in v1)
|
||||
- …
|
||||
```
|
||||
|
||||
## Vraag-richtlijnen
|
||||
|
||||
- **Scherp & specifiek**, geen open "wat denk je ervan?".
|
||||
- Bij twijfel: bied **multi-choice** via `options: ["A", "B", "C"]`.
|
||||
- Stel **één vraag per cyclus** — niet meerdere geneste.
|
||||
- Vermijd vragen waarvan het antwoord uit de repo te lezen is — lees zelf.
|
||||
- Geen meta-vragen ("zal ik nog meer vragen?"). Beslis zelf wanneer je stopt.
|
||||
|
||||
## Foutgevallen
|
||||
|
||||
- Vraag verloopt (24h): roep `update_job_status('failed', error: 'question expired')`.
|
||||
- Repo niet leesbaar: roep `update_job_status('failed', error: 'repo access')`.
|
||||
- Gebruiker annuleert via UI: job wordt door server op CANCELLED gezet; je krijgt geen verdere antwoorden — sluit netjes af.
|
||||
|
||||
## Voorbeeld-vraag
|
||||
|
||||
```
|
||||
ask_user_question({
|
||||
idea_id,
|
||||
question: "Moet 'Plant-watering reminder' alleen lokale notifications doen, of ook web-push?",
|
||||
options: ["Alleen lokaal (eenvoud)", "Web-push (multi-device)", "Beide"],
|
||||
})
|
||||
```
|
||||
129
lib/idea-prompts/make-plan.md
Normal file
129
lib/idea-prompts/make-plan.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs
|
||||
|
||||
> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een
|
||||
> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze
|
||||
> 8). Twijfels → terug naar grill via UI.
|
||||
|
||||
---
|
||||
|
||||
Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`.
|
||||
|
||||
Je context (meegegeven in `wait_for_job`-payload):
|
||||
|
||||
- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je
|
||||
primaire input.
|
||||
- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als
|
||||
referentie.
|
||||
- `product`: gekoppeld product met `repo_url`, `definition_of_done`,
|
||||
bestaande architectuur in repo.
|
||||
|
||||
## Doel
|
||||
|
||||
Eén `plan_md` produceren die je via `mcp__scrum4me__update_idea_plan_md`
|
||||
opslaat. Dit document wordt later **deterministisch** geparseerd door de
|
||||
server-side `parsePlanMd` (zie `lib/idea-plan-parser.ts`) en omgezet in
|
||||
PBI + stories + taken via `materializeIdeaPlanAction`.
|
||||
|
||||
## Werkwijze (single-pass)
|
||||
|
||||
1. Lees `idea.grill_md` volledig.
|
||||
2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur.
|
||||
3. Bouw het plan op in de **strikte format** hieronder.
|
||||
4. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`.
|
||||
5. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`.
|
||||
|
||||
## STEL GEEN VRAGEN
|
||||
|
||||
`mcp__scrum4me__ask_user_question` is in deze fase **verboden**. Als je
|
||||
informatie mist die je nodig hebt om het plan compleet te maken, schrijf je
|
||||
plan met je beste aanname en documenteer je in de **Body** (zie hieronder)
|
||||
welke aannames je hebt gemaakt. De gebruiker beoordeelt het plan in `PLAN_READY`
|
||||
en kan dan handmatig editen of een re-grill triggeren.
|
||||
|
||||
## Output-format (strikt — frontmatter wordt server-side geparseerd)
|
||||
|
||||
````markdown
|
||||
---
|
||||
pbi:
|
||||
title: "Korte PBI-titel (≤200 chars)"
|
||||
description: |
|
||||
1-3 zinnen die de PBI samenvatten.
|
||||
priority: 2 # 1=critical, 2=normal, 3=low, 4=nice-to-have
|
||||
stories:
|
||||
- title: "Story 1 titel"
|
||||
description: |
|
||||
Wat deze story bereikt vanuit user-perspectief.
|
||||
acceptance_criteria: |
|
||||
- AC 1
|
||||
- AC 2
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: "Taak A"
|
||||
description: "Korte beschrijving."
|
||||
implementation_plan: |
|
||||
1. Bestand X aanpassen — concrete steps
|
||||
2. Test toevoegen Y
|
||||
3. Verifieer Z
|
||||
priority: 2
|
||||
verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY
|
||||
verify_only: false # true voor pure verify-passes
|
||||
- title: "Taak B"
|
||||
priority: 2
|
||||
implementation_plan: |
|
||||
...
|
||||
- title: "Story 2 titel"
|
||||
priority: 2
|
||||
tasks:
|
||||
- title: "..."
|
||||
priority: 2
|
||||
---
|
||||
|
||||
# Overwegingen
|
||||
|
||||
(Vrije body — niet geparsed door materialize, wordt opgeslagen in
|
||||
IdeaLog{PLAN_RESULT}.metadata.body voor latere referentie.)
|
||||
|
||||
Beschrijf:
|
||||
- Waarom deze opdeling in stories/taken
|
||||
- Welke aannames je hebt gemaakt (indien grill onvolledig was)
|
||||
- Architectuur-keuzes & verwijzingen naar bestaande modules in repo
|
||||
|
||||
# Alternatieven
|
||||
|
||||
- Optie X (verworpen omdat …)
|
||||
- Optie Y (overwogen voor v2 …)
|
||||
|
||||
# Beslissingen
|
||||
|
||||
- ...
|
||||
|
||||
# Aannames (indien van toepassing)
|
||||
|
||||
- ...
|
||||
````
|
||||
|
||||
## Validatie-regels die de parser afdwingt
|
||||
|
||||
- `pbi.title`: 1–200 chars, **verplicht**.
|
||||
- `pbi.priority`, `story.priority`, `task.priority`: integer 1–4.
|
||||
- Minimaal 1 story; per story minimaal 1 taak.
|
||||
- `implementation_plan`: max 8000 chars.
|
||||
- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`.
|
||||
- Alle string-velden trimmen, geen lege strings.
|
||||
|
||||
Een parse-fout zet het idee op `PLAN_FAILED`. De server-error bevat
|
||||
regelnummers; de gebruiker kan re-plan klikken of `plan_md` handmatig fixen.
|
||||
|
||||
## Schaal-richtlijnen (geen harde limieten)
|
||||
|
||||
- 1 PBI per idee.
|
||||
- 2–6 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau).
|
||||
- 2–5 taken per story.
|
||||
- Eén taak ≈ 30 min – paar uur werk; **`implementation_plan` is concreet**
|
||||
(bestandsnamen, commando's, regels code), niet abstract.
|
||||
|
||||
## Voorbeelden van goede vs slechte taken
|
||||
|
||||
❌ **Slecht**: "Maak de feature werkend"
|
||||
✅ **Goed**: "Voeg `actions/ideas.ts:createIdeaAction(input)` toe — auth +
|
||||
demo-403 + zod-parse + nextIdeaCode + prisma.idea.create + revalidatePath"
|
||||
56
lib/idea-status-colors.ts
Normal file
56
lib/idea-status-colors.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Mapping van IdeaStatus → Tailwind/MD3-classes voor badge-rendering.
|
||||
// Hergebruikt de bestaande --status-*-tokens (zie app/styles/theme.css).
|
||||
// CLAUDE.md hardstop: nooit `bg-blue-500` o.i.d.; altijd MD3-tokens.
|
||||
|
||||
import type { IdeaStatus } from '@prisma/client'
|
||||
|
||||
export interface IdeaStatusBadge {
|
||||
label: string
|
||||
classes: string
|
||||
pulse?: boolean
|
||||
}
|
||||
|
||||
const PILL = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium'
|
||||
|
||||
// Per-status: label + Tailwind-classes + optionele pulse-indicator.
|
||||
// in-progress + status-blocked + status-review + status-done worden hergebruikt.
|
||||
const TABLE: Record<IdeaStatus, IdeaStatusBadge> = {
|
||||
DRAFT: {
|
||||
label: 'Concept',
|
||||
classes: `${PILL} bg-surface-variant text-on-surface-variant border-outline-variant`,
|
||||
},
|
||||
GRILLING: {
|
||||
label: 'Grillen…',
|
||||
classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`,
|
||||
pulse: true,
|
||||
},
|
||||
GRILL_FAILED: {
|
||||
label: 'Grill mislukt',
|
||||
classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`,
|
||||
},
|
||||
GRILLED: {
|
||||
label: 'Gegrilld',
|
||||
classes: `${PILL} bg-status-review/15 text-status-review border-status-review/30`,
|
||||
},
|
||||
PLANNING: {
|
||||
label: 'Plannen…',
|
||||
classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`,
|
||||
pulse: true,
|
||||
},
|
||||
PLAN_FAILED: {
|
||||
label: 'Plan mislukt',
|
||||
classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`,
|
||||
},
|
||||
PLAN_READY: {
|
||||
label: 'Plan klaar',
|
||||
classes: `${PILL} bg-status-review/15 text-status-review border-status-review/30`,
|
||||
},
|
||||
PLANNED: {
|
||||
label: 'Gepland',
|
||||
classes: `${PILL} bg-status-done/15 text-status-done border-status-done/30`,
|
||||
},
|
||||
}
|
||||
|
||||
export function getIdeaStatusBadge(status: IdeaStatus): IdeaStatusBadge {
|
||||
return TABLE[status]
|
||||
}
|
||||
85
lib/idea-status.ts
Normal file
85
lib/idea-status.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Bidirectionele case-mapper voor IdeaStatus + transitie-guard helper.
|
||||
// DB houdt UPPER_SNAKE; API exposeert lowercase.
|
||||
// Patroon volgt lib/task-status.ts.
|
||||
|
||||
import type { IdeaStatus } from '@prisma/client'
|
||||
|
||||
const IDEA_DB_TO_API = {
|
||||
DRAFT: 'draft',
|
||||
GRILLING: 'grilling',
|
||||
GRILL_FAILED: 'grill_failed',
|
||||
GRILLED: 'grilled',
|
||||
PLANNING: 'planning',
|
||||
PLAN_FAILED: 'plan_failed',
|
||||
PLAN_READY: 'plan_ready',
|
||||
PLANNED: 'planned',
|
||||
} as const satisfies Record<IdeaStatus, string>
|
||||
|
||||
const IDEA_API_TO_DB: Record<string, IdeaStatus> = {
|
||||
draft: 'DRAFT',
|
||||
grilling: 'GRILLING',
|
||||
grill_failed: 'GRILL_FAILED',
|
||||
grilled: 'GRILLED',
|
||||
planning: 'PLANNING',
|
||||
plan_failed: 'PLAN_FAILED',
|
||||
plan_ready: 'PLAN_READY',
|
||||
planned: 'PLANNED',
|
||||
}
|
||||
|
||||
export type IdeaStatusApi = (typeof IDEA_DB_TO_API)[IdeaStatus]
|
||||
|
||||
export function ideaStatusToApi(s: IdeaStatus): IdeaStatusApi {
|
||||
return IDEA_DB_TO_API[s]
|
||||
}
|
||||
|
||||
export function ideaStatusFromApi(s: string): IdeaStatus | null {
|
||||
return IDEA_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export const IDEA_STATUS_API_VALUES = Object.values(IDEA_DB_TO_API)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State-machine transition table (zie docs/plans/M12-ideas.md state-machine).
|
||||
// Server-actions gebruiken canTransition(from, to) als guard vóór mutatie.
|
||||
//
|
||||
// Asymmetrisch: trek vanuit DRAFT alleen naar GRILLING; vanuit GRILLED kan
|
||||
// re-grill (→ GRILLING) of make-plan (→ PLANNING) gebeuren. PLANNED is een
|
||||
// terminal state; verlaat alleen via expliciete relink (PBI verwijderd → PLAN_READY).
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<IdeaStatus, ReadonlyArray<IdeaStatus>> = {
|
||||
DRAFT: ['GRILLING'],
|
||||
GRILLING: ['GRILLED', 'GRILL_FAILED'],
|
||||
GRILL_FAILED: ['GRILLING', 'DRAFT'],
|
||||
GRILLED: ['GRILLING', 'PLANNING'],
|
||||
PLANNING: ['PLAN_READY', 'PLAN_FAILED'],
|
||||
PLAN_FAILED: ['PLANNING', 'GRILLED'],
|
||||
PLAN_READY: ['PLANNING', 'PLANNED'],
|
||||
PLANNED: ['PLAN_READY'], // alleen via relinkIdeaPlanAction (PBI deleted)
|
||||
}
|
||||
|
||||
export function canTransition(from: IdeaStatus, to: IdeaStatus): boolean {
|
||||
return ALLOWED_TRANSITIONS[from].includes(to)
|
||||
}
|
||||
|
||||
// Statussen waarin een idee bewerkbaar is (form-input, niet md-velden).
|
||||
const EDITABLE_STATUSES: ReadonlyArray<IdeaStatus> = [
|
||||
'DRAFT',
|
||||
'GRILL_FAILED',
|
||||
'GRILLED',
|
||||
'PLAN_FAILED',
|
||||
'PLAN_READY',
|
||||
]
|
||||
|
||||
export function isIdeaEditable(s: IdeaStatus): boolean {
|
||||
return EDITABLE_STATUSES.includes(s)
|
||||
}
|
||||
|
||||
// Statussen waarin grill_md bewerkbaar is (handmatige finetuning).
|
||||
export function isGrillMdEditable(s: IdeaStatus): boolean {
|
||||
return s === 'GRILLED' || s === 'PLAN_READY'
|
||||
}
|
||||
|
||||
// Statussen waarin plan_md bewerkbaar is.
|
||||
export function isPlanMdEditable(s: IdeaStatus): boolean {
|
||||
return s === 'PLAN_READY'
|
||||
}
|
||||
|
|
@ -40,6 +40,9 @@ export async function getVerifyResultStats(
|
|||
status: 'DONE' as const,
|
||||
verify_result: { not: null as null },
|
||||
finished_at: { gt: cutoff },
|
||||
// Note: task_id can now be NULL on idea-jobs (M12). The toTopJob mapper
|
||||
// filters them out via .filter(Boolean). Keeping a where-side filter
|
||||
// (`task_id: { not: null }`) is rejected by Prisma 7 runtime.
|
||||
}
|
||||
|
||||
const [grouped, rawEmpty, rawDivergent] = await Promise.all([
|
||||
|
|
@ -82,7 +85,8 @@ export async function getVerifyResultStats(
|
|||
.filter(r => countMap.has(r))
|
||||
.map(r => ({ result: r, count: countMap.get(r)! }))
|
||||
|
||||
function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string }; product: { id: string; name: string } }): TopJob {
|
||||
function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string } | null; product: { id: string; name: string } }): TopJob | null {
|
||||
if (!j.task) return null
|
||||
return {
|
||||
jobId: j.id,
|
||||
taskId: j.task.id,
|
||||
|
|
@ -95,8 +99,8 @@ export async function getVerifyResultStats(
|
|||
|
||||
return {
|
||||
counts,
|
||||
topEmpty: rawEmpty.map(toTopJob),
|
||||
topDivergent: rawDivergent.map(toTopJob),
|
||||
topEmpty: rawEmpty.map(toTopJob).filter((j): j is TopJob => j !== null),
|
||||
topDivergent: rawDivergent.map(toTopJob).filter((j): j is TopJob => j !== null),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ const CONFIGS: Record<string, RateLimitConfig> = {
|
|||
'log-story': { windowMs: 60_000, max: 60 },
|
||||
'upload-avatar': { windowMs: 3_600_000, max: 20 },
|
||||
'answer-question': { windowMs: 60_000, max: 30 },
|
||||
|
||||
// M12 — Idea entity (zie docs/plans/M12-ideas.md)
|
||||
'create-idea': { windowMs: 60_000, max: 30 },
|
||||
'edit-idea-md': { windowMs: 60_000, max: 60 }, // grill_md / plan_md edits
|
||||
'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers
|
||||
'materialize-idea': { windowMs: 60_000, max: 5 },
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }
|
||||
|
|
|
|||
|
|
@ -12,21 +12,52 @@
|
|||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
||||
import { useIdeaStore } from '@/stores/idea-store'
|
||||
|
||||
const BACKOFF_START_MS = 1_000
|
||||
const BACKOFF_MAX_MS = 30_000
|
||||
|
||||
interface NotifyPayload {
|
||||
// Question-payloads (M11 + M12). story_id en idea_id zijn mutually exclusive
|
||||
// (DB-check-constraint). Voor story-questions blijft het pad onveranderd;
|
||||
// idea-questions worden naar de idea-store doorgezet.
|
||||
interface QuestionPayload {
|
||||
op: 'I' | 'U'
|
||||
entity: 'question'
|
||||
id: string
|
||||
product_id: string
|
||||
story_id: string
|
||||
story_id: string | null
|
||||
task_id: string | null
|
||||
idea_id?: string | null
|
||||
assignee_id: string | null
|
||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
}
|
||||
|
||||
// Idea-job-payloads (M12). Komen uit actions/ideas.ts pg_notify.
|
||||
interface IdeaJobPayload {
|
||||
type: 'claude_job_enqueued' | 'claude_job_status'
|
||||
job_id: string
|
||||
idea_id: string
|
||||
user_id: string
|
||||
product_id?: string | null
|
||||
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
|
||||
status: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type AnyPayload = QuestionPayload | IdeaJobPayload
|
||||
|
||||
function isQuestionPayload(p: AnyPayload): p is QuestionPayload {
|
||||
return 'entity' in p && p.entity === 'question'
|
||||
}
|
||||
|
||||
function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload {
|
||||
return (
|
||||
'type' in p &&
|
||||
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
|
||||
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN')
|
||||
)
|
||||
}
|
||||
|
||||
interface StateEvent {
|
||||
questions: NotificationQuestion[]
|
||||
}
|
||||
|
|
@ -73,11 +104,44 @@ export function useNotificationsRealtime() {
|
|||
|
||||
source.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const payload = JSON.parse(ev.data) as NotifyPayload
|
||||
if (payload.entity !== 'question') return
|
||||
// Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst
|
||||
// mee in de payload, dus we doen een mini-fetch via de same SSE's
|
||||
// initial-state on reconnect; hier voor MVP alleen status-handling).
|
||||
const payload = JSON.parse(ev.data) as AnyPayload
|
||||
|
||||
// M12 — idea-job events naar idea-store dispatchen.
|
||||
if (isIdeaJobPayload(payload)) {
|
||||
useIdeaStore.getState().handleIdeaJobEvent({
|
||||
type: payload.type,
|
||||
job_id: payload.job_id,
|
||||
idea_id: payload.idea_id,
|
||||
user_id: payload.user_id,
|
||||
product_id: payload.product_id ?? null,
|
||||
kind: payload.kind,
|
||||
// The store-types narrow this; cast is safe because the server
|
||||
// emits valid statuses.
|
||||
status: payload.status as 'queued',
|
||||
error: payload.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!isQuestionPayload(payload)) return
|
||||
|
||||
// M12 — idea-question events naar idea-store dispatchen.
|
||||
if (payload.idea_id) {
|
||||
useIdeaStore.getState().handleIdeaQuestionEvent({
|
||||
op: payload.op,
|
||||
entity: 'question',
|
||||
id: payload.id,
|
||||
product_id: payload.product_id,
|
||||
story_id: null,
|
||||
idea_id: payload.idea_id,
|
||||
task_id: payload.task_id,
|
||||
assignee_id: payload.assignee_id,
|
||||
status: payload.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Story-questions: bestaande bell-pad onveranderd.
|
||||
if (payload.status === 'open') {
|
||||
// Inkomende open vraag: we hebben de details nog niet — beste optie is
|
||||
// herfetchen door opnieuw te verbinden, of via een API. Voor v1
|
||||
|
|
|
|||
53
lib/schemas/idea.ts
Normal file
53
lib/schemas/idea.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
// Velden die de gebruiker invult bij create/edit. Status wordt door
|
||||
// server-actions gezet (niet door client-input).
|
||||
export const ideaCreateSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Titel is verplicht').max(200, 'Maximaal 200 tekens'),
|
||||
description: z.string().max(4000, 'Maximaal 4000 tekens').optional().nullable(),
|
||||
product_id: z.string().cuid('Ongeldig product').optional().nullable(),
|
||||
})
|
||||
|
||||
export const ideaUpdateSchema = ideaCreateSchema.partial()
|
||||
|
||||
export type IdeaCreateInput = z.infer<typeof ideaCreateSchema>
|
||||
export type IdeaUpdateInput = z.infer<typeof ideaUpdateSchema>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// plan_md frontmatter — strict format dat door make-plan-job geproduceerd
|
||||
// wordt en door materializeIdeaPlanAction wordt geparseerd. Zie
|
||||
// docs/plans/M12-ideas.md "Plan-md formaat A".
|
||||
|
||||
const verifyRequiredEnum = z.enum(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'])
|
||||
|
||||
const planTaskSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(4000).optional(),
|
||||
implementation_plan: z.string().max(8000).optional(),
|
||||
priority: z.number().int().min(1).max(4),
|
||||
verify_required: verifyRequiredEnum.optional(),
|
||||
verify_only: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const planStorySchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(4000).optional(),
|
||||
acceptance_criteria: z.string().max(4000).optional(),
|
||||
priority: z.number().int().min(1).max(4),
|
||||
tasks: z.array(planTaskSchema).min(1, 'Story moet minimaal 1 taak hebben'),
|
||||
})
|
||||
|
||||
const planPbiSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(4000).optional(),
|
||||
priority: z.number().int().min(1).max(4),
|
||||
})
|
||||
|
||||
export const ideaPlanMdFrontmatterSchema = z.object({
|
||||
pbi: planPbiSchema,
|
||||
stories: z.array(planStorySchema).min(1, 'Plan moet minimaal 1 story bevatten'),
|
||||
})
|
||||
|
||||
export type IdeaPlanFrontmatter = z.infer<typeof ideaPlanMdFrontmatterSchema>
|
||||
export type IdeaPlanStory = z.infer<typeof planStorySchema>
|
||||
export type IdeaPlanTask = z.infer<typeof planTaskSchema>
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
|||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
|
@ -21518,10 +21519,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yaml": "^2.8.4",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -74,6 +74,32 @@ enum SprintStatus {
|
|||
COMPLETED
|
||||
}
|
||||
|
||||
enum IdeaStatus {
|
||||
DRAFT
|
||||
GRILLING
|
||||
GRILL_FAILED
|
||||
GRILLED
|
||||
PLANNING
|
||||
PLAN_FAILED
|
||||
PLAN_READY
|
||||
PLANNED
|
||||
}
|
||||
|
||||
enum ClaudeJobKind {
|
||||
TASK_IMPLEMENTATION
|
||||
IDEA_GRILL
|
||||
IDEA_MAKE_PLAN
|
||||
}
|
||||
|
||||
enum IdeaLogType {
|
||||
DECISION
|
||||
NOTE
|
||||
GRILL_RESULT
|
||||
PLAN_RESULT
|
||||
STATUS_CHANGE
|
||||
JOB_EVENT
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
|
|
@ -85,12 +111,14 @@ model User {
|
|||
avatar_data Bytes?
|
||||
active_product_id String?
|
||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||
idea_code_counter Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
api_tokens ApiToken[]
|
||||
products Product[]
|
||||
todos Todo[]
|
||||
ideas Idea[]
|
||||
product_members ProductMember[]
|
||||
assigned_stories Story[] @relation("StoryAssignee")
|
||||
login_pairings LoginPairing[]
|
||||
|
|
@ -150,6 +178,7 @@ model Product {
|
|||
active_for_users User[] @relation("UserActiveProduct")
|
||||
claude_questions ClaudeQuestion[]
|
||||
claude_jobs ClaudeJob[]
|
||||
ideas Idea[]
|
||||
|
||||
@@unique([user_id, name])
|
||||
@@unique([user_id, code])
|
||||
|
|
@ -172,6 +201,7 @@ model Pbi {
|
|||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
stories Story[]
|
||||
idea Idea?
|
||||
|
||||
@@unique([product_id, code])
|
||||
@@index([product_id, priority, sort_order])
|
||||
|
|
@ -283,8 +313,11 @@ model ClaudeJob {
|
|||
user_id String
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String
|
||||
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
||||
task_id String
|
||||
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
||||
task_id String?
|
||||
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
idea_id String?
|
||||
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
|
||||
status ClaudeJobStatus @default(QUEUED)
|
||||
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
||||
claimed_by_token_id String?
|
||||
|
|
@ -304,6 +337,7 @@ model ClaudeJob {
|
|||
|
||||
@@index([user_id, status])
|
||||
@@index([task_id, status])
|
||||
@@index([idea_id, status])
|
||||
@@index([status, claimed_at])
|
||||
@@index([status, finished_at])
|
||||
@@map("claude_jobs")
|
||||
|
|
@ -355,6 +389,47 @@ model Todo {
|
|||
@@map("todos")
|
||||
}
|
||||
|
||||
model Idea {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
||||
product_id String?
|
||||
code String @db.VarChar(30)
|
||||
title String
|
||||
description String? @db.VarChar(4000)
|
||||
grill_md String? @db.Text
|
||||
plan_md String? @db.Text
|
||||
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
|
||||
pbi_id String? @unique
|
||||
status IdeaStatus @default(DRAFT)
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
questions ClaudeQuestion[]
|
||||
jobs ClaudeJob[]
|
||||
logs IdeaLog[]
|
||||
|
||||
@@unique([user_id, code])
|
||||
@@index([user_id, archived, status])
|
||||
@@index([user_id, product_id])
|
||||
@@map("ideas")
|
||||
}
|
||||
|
||||
model IdeaLog {
|
||||
id String @id @default(cuid())
|
||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
idea_id String
|
||||
type IdeaLogType
|
||||
content String @db.Text
|
||||
metadata Json?
|
||||
created_at DateTime @default(now())
|
||||
|
||||
@@index([idea_id, created_at])
|
||||
@@map("idea_logs")
|
||||
}
|
||||
|
||||
model LoginPairing {
|
||||
id String @id @default(cuid())
|
||||
secret_hash String
|
||||
|
|
@ -376,10 +451,12 @@ model LoginPairing {
|
|||
|
||||
model ClaudeQuestion {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||
story_id String
|
||||
story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||
story_id String?
|
||||
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
||||
task_id String?
|
||||
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
idea_id String?
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
|
||||
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
||||
|
|
@ -395,6 +472,7 @@ model ClaudeQuestion {
|
|||
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
|
||||
|
||||
@@index([story_id, status])
|
||||
@@index([idea_id, status])
|
||||
@@index([product_id, status])
|
||||
@@index([status, expires_at])
|
||||
@@map("claude_questions")
|
||||
|
|
|
|||
2
proxy.ts
2
proxy.ts
|
|
@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
|
|||
import { unsealData } from 'iron-session'
|
||||
import { sessionOptions, type SessionData } from '@/lib/session'
|
||||
|
||||
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo']
|
||||
const protectedRoutes = ['/dashboard', '/products', '/todos', '/ideas', '/settings', '/solo']
|
||||
const authRoutes = ['/login', '/register']
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
|
|
|||
182
stores/idea-store.ts
Normal file
182
stores/idea-store.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// M12: Zustand-store voor idee-gerelateerde realtime state.
|
||||
//
|
||||
// Wordt gevoed door `use-notifications-realtime.ts` (zelfde SSE-stream als de
|
||||
// notifications-bell — geen tweede EventSource nodig). Houdt:
|
||||
// - jobByIdea: live status van de actieve grill/make-plan-job per idee
|
||||
// - ideaStatuses: optimistische idea-status-updates (uit job-events)
|
||||
// - openQuestionsByIdea: open vragen voor de Timeline-tab (M12 ST-1199)
|
||||
//
|
||||
// connectedWorkers wordt NIET gedupliceerd — UI-componenten lezen die direct
|
||||
// via `useSoloStore(s => s.connectedWorkers)` (zie M12 grill-keuze 16).
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||
import type { IdeaStatusApi } from '@/lib/idea-status'
|
||||
|
||||
export type IdeaJobKind = 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
|
||||
|
||||
export interface IdeaJobState {
|
||||
job_id: string
|
||||
idea_id: string
|
||||
kind: IdeaJobKind
|
||||
status: ClaudeJobStatusApi
|
||||
error?: string
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
|
||||
export interface IdeaQuestion {
|
||||
id: string
|
||||
idea_id: string
|
||||
question: string
|
||||
options: string[] | null
|
||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
answer?: string | null
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export type IdeaJobEvent =
|
||||
| {
|
||||
type: 'claude_job_enqueued'
|
||||
job_id: string
|
||||
idea_id: string
|
||||
user_id: string
|
||||
product_id?: string | null
|
||||
kind: IdeaJobKind
|
||||
status: 'queued'
|
||||
}
|
||||
| {
|
||||
type: 'claude_job_status'
|
||||
job_id: string
|
||||
idea_id: string
|
||||
user_id: string
|
||||
product_id?: string | null
|
||||
kind: IdeaJobKind
|
||||
status: ClaudeJobStatusApi
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type IdeaQuestionEvent = {
|
||||
op: 'I' | 'U'
|
||||
entity: 'question'
|
||||
id: string
|
||||
product_id: string
|
||||
story_id: null
|
||||
idea_id: string
|
||||
task_id?: string | null
|
||||
assignee_id?: string | null
|
||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
}
|
||||
|
||||
interface IdeaStore {
|
||||
jobByIdea: Record<string, IdeaJobState | undefined>
|
||||
ideaStatuses: Record<string, IdeaStatusApi | undefined>
|
||||
openQuestionsByIdea: Record<string, IdeaQuestion[]>
|
||||
|
||||
// Bulk-init bij mount van een page (server-component → client hydration).
|
||||
initJobs: (jobs: IdeaJobState[]) => void
|
||||
initStatuses: (statuses: Record<string, IdeaStatusApi>) => void
|
||||
initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void
|
||||
|
||||
// Realtime event handlers — aangeroepen door use-notifications-realtime.
|
||||
handleIdeaJobEvent: (event: IdeaJobEvent) => void
|
||||
handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void
|
||||
|
||||
// Optimistic updates vanuit server-actions in client-components.
|
||||
setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void
|
||||
setJobStatus: (job: IdeaJobState) => void
|
||||
|
||||
// Cleanup bij navigeren weg van een detail-pagina.
|
||||
clearForIdea: (ideaId: string) => void
|
||||
}
|
||||
|
||||
// Mapping van een job-status (uit pg_notify event) naar een afgeleide
|
||||
// idea-status. De server is de bron-van-waarheid; dit is alleen optimistic UI.
|
||||
function deriveIdeaStatusFromJob(
|
||||
kind: IdeaJobKind,
|
||||
status: ClaudeJobStatusApi,
|
||||
): IdeaStatusApi | null {
|
||||
if (status === 'queued' || status === 'claimed' || status === 'running') {
|
||||
return kind === 'IDEA_GRILL' ? 'grilling' : 'planning'
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return kind === 'IDEA_GRILL' ? 'grill_failed' : 'plan_failed'
|
||||
}
|
||||
// 'done' wordt door update_idea_*_md gezet (GRILLED resp. PLAN_READY) —
|
||||
// daar is geen kind-onafhankelijke afleiding voor; lees de DB-update via
|
||||
// re-fetch / page-revalidate. We laten de status hier ongemoeid.
|
||||
return null
|
||||
}
|
||||
|
||||
export const useIdeaStore = create<IdeaStore>((set) => ({
|
||||
jobByIdea: {},
|
||||
ideaStatuses: {},
|
||||
openQuestionsByIdea: {},
|
||||
|
||||
initJobs: (jobs) =>
|
||||
set(() => {
|
||||
const jobByIdea: Record<string, IdeaJobState> = {}
|
||||
for (const j of jobs) jobByIdea[j.idea_id] = j
|
||||
return { jobByIdea }
|
||||
}),
|
||||
|
||||
initStatuses: (statuses) => set({ ideaStatuses: { ...statuses } }),
|
||||
|
||||
initQuestions: (ideaId, questions) =>
|
||||
set((s) => ({
|
||||
openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions },
|
||||
})),
|
||||
|
||||
handleIdeaJobEvent: (event) =>
|
||||
set((s) => {
|
||||
const jobState: IdeaJobState = {
|
||||
job_id: event.job_id,
|
||||
idea_id: event.idea_id,
|
||||
kind: event.kind,
|
||||
status: event.status as ClaudeJobStatusApi,
|
||||
error: 'error' in event ? event.error : undefined,
|
||||
}
|
||||
const derived = deriveIdeaStatusFromJob(event.kind, event.status as ClaudeJobStatusApi)
|
||||
return {
|
||||
jobByIdea: { ...s.jobByIdea, [event.idea_id]: jobState },
|
||||
ideaStatuses:
|
||||
derived !== null
|
||||
? { ...s.ideaStatuses, [event.idea_id]: derived }
|
||||
: s.ideaStatuses,
|
||||
}
|
||||
}),
|
||||
|
||||
handleIdeaQuestionEvent: (event) =>
|
||||
set((s) => {
|
||||
const list = s.openQuestionsByIdea[event.idea_id] ?? []
|
||||
// Bij open/insert: we hebben alleen status + id; de UI fetcht de
|
||||
// detail bij re-render. Voor v1 markeren we 'm in de lijst zodat de
|
||||
// count niet uit sync raakt.
|
||||
let next = list
|
||||
if (event.status !== 'open') {
|
||||
next = list.filter((q) => q.id !== event.id)
|
||||
}
|
||||
return {
|
||||
openQuestionsByIdea: { ...s.openQuestionsByIdea, [event.idea_id]: next },
|
||||
}
|
||||
}),
|
||||
|
||||
setIdeaStatus: (ideaId, status) =>
|
||||
set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })),
|
||||
|
||||
setJobStatus: (job) =>
|
||||
set((s) => ({ jobByIdea: { ...s.jobByIdea, [job.idea_id]: job } })),
|
||||
|
||||
clearForIdea: (ideaId) =>
|
||||
set((s) => {
|
||||
const { [ideaId]: _j, ...jobByIdea } = s.jobByIdea
|
||||
const { [ideaId]: _s, ...ideaStatuses } = s.ideaStatuses
|
||||
const { [ideaId]: _q, ...openQuestionsByIdea } = s.openQuestionsByIdea
|
||||
void _j
|
||||
void _s
|
||||
void _q
|
||||
return { jobByIdea, ideaStatuses, openQuestionsByIdea }
|
||||
}),
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue