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: {
|
prisma: {
|
||||||
product: { findMany: vi.fn() },
|
product: { findMany: vi.fn() },
|
||||||
claudeQuestion: { 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', () => {
|
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 () => {
|
it('demo + POST /api/todos → 403', async () => {
|
||||||
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
|
||||||
const req = makeRequest('POST', '/api/todos', 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 }
|
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[]) {
|
export async function updateRolesAction(roles: string[]) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
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 HEARTBEAT_MS = 25_000
|
||||||
const HARD_CLOSE_MS = 240_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'
|
op: 'I' | 'U'
|
||||||
entity: 'task' | 'story' | 'question'
|
entity: 'question'
|
||||||
id: string
|
id: string
|
||||||
product_id: string
|
product_id: string
|
||||||
story_id?: string
|
story_id?: string | null
|
||||||
task_id?: string | null
|
task_id?: string | null
|
||||||
|
idea_id?: string | null
|
||||||
assignee_id?: string | null
|
assignee_id?: string | null
|
||||||
status?: string
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
|
|
@ -53,6 +85,15 @@ export async function GET(request: NextRequest) {
|
||||||
})
|
})
|
||||||
const accessibleProductIds = new Set(products.map((p) => p.id))
|
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
|
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
|
||||||
if (!directUrl) {
|
if (!directUrl) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|
@ -115,7 +156,24 @@ export async function GET(request: NextRequest) {
|
||||||
} catch {
|
} catch {
|
||||||
return
|
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
|
if (!accessibleProductIds.has(payload.product_id)) return
|
||||||
enqueue(`data: ${msg.payload}\n\n`)
|
enqueue(`data: ${msg.payload}\n\n`)
|
||||||
})
|
})
|
||||||
|
|
@ -132,6 +190,9 @@ export async function GET(request: NextRequest) {
|
||||||
status: 'open',
|
status: 'open',
|
||||||
expires_at: { gt: new Date() },
|
expires_at: { gt: new Date() },
|
||||||
product_id: { in: products.map((p) => p.id) },
|
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' },
|
orderBy: { created_at: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
|
|
@ -150,7 +211,9 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
enqueue(
|
enqueue(
|
||||||
`event: state\ndata: ${JSON.stringify({
|
`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,
|
id: q.id,
|
||||||
product_id: q.product_id,
|
product_id: q.product_id,
|
||||||
story_id: q.story_id,
|
story_id: q.story_id,
|
||||||
|
|
@ -162,7 +225,8 @@ export async function GET(request: NextRequest) {
|
||||||
options: q.options,
|
options: q.options,
|
||||||
created_at: q.created_at.toISOString(),
|
created_at: q.created_at.toISOString(),
|
||||||
expires_at: q.expires_at.toISOString(),
|
expires_at: q.expires_at.toISOString(),
|
||||||
})),
|
}]
|
||||||
|
}),
|
||||||
})}\n\n`,
|
})}\n\n`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,11 @@ type EntityPayload = {
|
||||||
type JobPayload = {
|
type JobPayload = {
|
||||||
type: 'claude_job_enqueued' | 'claude_job_status'
|
type: 'claude_job_enqueued' | 'claude_job_status'
|
||||||
job_id: string
|
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
|
user_id: string
|
||||||
product_id: string
|
product_id: string
|
||||||
status: string
|
status: string
|
||||||
|
|
@ -77,6 +81,8 @@ function shouldEmit(
|
||||||
userId: string,
|
userId: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (isJobPayload(payload)) {
|
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
|
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',
|
status: 'open',
|
||||||
expires_at: { gt: new Date() },
|
expires_at: { gt: new Date() },
|
||||||
product_id: { in: productIds },
|
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' },
|
orderBy: { created_at: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
|
|
@ -44,19 +48,22 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const initial: NotificationQuestion[] = openQuestions.map((q) => ({
|
const initial: NotificationQuestion[] = openQuestions.flatMap((q) => {
|
||||||
id: q.id,
|
if (!q.story || q.story_id === null) return []
|
||||||
product_id: q.product_id,
|
return [{
|
||||||
story_id: q.story_id,
|
id: q.id,
|
||||||
task_id: q.task_id,
|
product_id: q.product_id,
|
||||||
story_code: q.story.code,
|
story_id: q.story_id,
|
||||||
story_title: q.story.title,
|
task_id: q.task_id,
|
||||||
assignee_id: q.story.assignee_id,
|
story_code: q.story.code,
|
||||||
question: q.question,
|
story_title: q.story.title,
|
||||||
options: Array.isArray(q.options) ? (q.options as string[]) : null,
|
assignee_id: q.story.assignee_id,
|
||||||
created_at: q.created_at.toISOString(),
|
question: q.question,
|
||||||
expires_at: q.expires_at.toISOString(),
|
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} />
|
return <NotificationsRealtimeMount initial={initial} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ export function NavBar({
|
||||||
)
|
)
|
||||||
: disabledSpan('Solo')}
|
: disabledSpan('Solo')}
|
||||||
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||||
|
{navLink('/ideas', 'Ideeën', pathname.startsWith('/ideas'))}
|
||||||
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
|
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useActionState } from 'react'
|
import { useActionState } from 'react'
|
||||||
import { useFormStatus } from 'react-dom'
|
import { useFormStatus } from 'react-dom'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
archiveSelectedTodosAction,
|
archiveSelectedTodosAction,
|
||||||
promoteTodoToPbiAction,
|
promoteTodoToPbiAction,
|
||||||
promoteTodoToStoryAction,
|
promoteTodoToStoryAction,
|
||||||
|
promoteTodoToIdeaAction,
|
||||||
} from '@/actions/todos'
|
} from '@/actions/todos'
|
||||||
|
|
||||||
interface Todo {
|
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 ---
|
// --- Detail card ---
|
||||||
function TodoCard({
|
function TodoCard({
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -243,6 +299,7 @@ function TodoCard({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onPromotePbi,
|
onPromotePbi,
|
||||||
onPromoteStory,
|
onPromoteStory,
|
||||||
|
onPromoteIdea,
|
||||||
}: {
|
}: {
|
||||||
mode: 'idle' | 'create' | 'edit'
|
mode: 'idle' | 'create' | 'edit'
|
||||||
activeTodo: Todo | null
|
activeTodo: Todo | null
|
||||||
|
|
@ -252,6 +309,7 @@ function TodoCard({
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
onPromotePbi: (todo: Todo) => void
|
onPromotePbi: (todo: Todo) => void
|
||||||
onPromoteStory: (todo: Todo) => void
|
onPromoteStory: (todo: Todo) => void
|
||||||
|
onPromoteIdea: (todo: Todo) => void
|
||||||
}) {
|
}) {
|
||||||
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
|
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
|
||||||
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
|
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
|
||||||
|
|
@ -366,6 +424,9 @@ function TodoCard({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isDemo && (
|
{!isDemo && (
|
||||||
<>
|
<>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteIdea(activeTodo)}>
|
||||||
|
→ Idee
|
||||||
|
</Button>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
|
||||||
→ PBI
|
→ PBI
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -393,6 +454,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
const [mode, setMode] = useState<'idle' | 'create'>('idle')
|
const [mode, setMode] = useState<'idle' | 'create'>('idle')
|
||||||
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
|
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
|
||||||
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
|
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
|
||||||
|
const [promoteIdea, setPromoteIdea] = useState<Todo | null>(null)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (selectedProductId === 'all') return todos
|
if (selectedProductId === 'all') return todos
|
||||||
|
|
@ -608,6 +670,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
onSuccess={handleCancel}
|
onSuccess={handleCancel}
|
||||||
onPromotePbi={setPromotePbi}
|
onPromotePbi={setPromotePbi}
|
||||||
onPromoteStory={setPromoteStory}
|
onPromoteStory={setPromoteStory}
|
||||||
|
onPromoteIdea={setPromoteIdea}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{promotePbi && (
|
{promotePbi && (
|
||||||
|
|
@ -616,6 +679,9 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
{promoteStory && (
|
{promoteStory && (
|
||||||
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
|
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
|
||||||
)}
|
)}
|
||||||
|
{promoteIdea && (
|
||||||
|
<PromoteIdeaDialog todo={promoteIdea} onClose={() => setPromoteIdea(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</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 |
|
| [AnswerModal Profiel](./specs/dialogs/answer-modal.md) | active | 2026-05-04 |
|
||||||
| [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.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 |
|
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
|
||||||
| [ProductDialog Profiel](./specs/dialogs/product.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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) | — | — |
|
| [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 |
|
| [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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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)
|
## 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
|
- [ ] 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
|
- `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag
|
||||||
|
|
||||||
**Job queue — agent worker mode (M13):**
|
**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__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator:
|
||||||
- `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`.
|
- `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)
|
## 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:
|
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.
|
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.
|
3. `update_job_status('done'|'failed')` aanroepen.
|
||||||
4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen.
|
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.
|
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,
|
status: 'DONE' as const,
|
||||||
verify_result: { not: null as null },
|
verify_result: { not: null as null },
|
||||||
finished_at: { gt: cutoff },
|
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([
|
const [grouped, rawEmpty, rawDivergent] = await Promise.all([
|
||||||
|
|
@ -82,7 +85,8 @@ export async function getVerifyResultStats(
|
||||||
.filter(r => countMap.has(r))
|
.filter(r => countMap.has(r))
|
||||||
.map(r => ({ result: r, count: countMap.get(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 {
|
return {
|
||||||
jobId: j.id,
|
jobId: j.id,
|
||||||
taskId: j.task.id,
|
taskId: j.task.id,
|
||||||
|
|
@ -95,8 +99,8 @@ export async function getVerifyResultStats(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
counts,
|
counts,
|
||||||
topEmpty: rawEmpty.map(toTopJob),
|
topEmpty: rawEmpty.map(toTopJob).filter((j): j is TopJob => j !== null),
|
||||||
topDivergent: rawDivergent.map(toTopJob),
|
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 },
|
'log-story': { windowMs: 60_000, max: 60 },
|
||||||
'upload-avatar': { windowMs: 3_600_000, max: 20 },
|
'upload-avatar': { windowMs: 3_600_000, max: 20 },
|
||||||
'answer-question': { windowMs: 60_000, max: 30 },
|
'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 }
|
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,52 @@
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
||||||
|
import { useIdeaStore } from '@/stores/idea-store'
|
||||||
|
|
||||||
const BACKOFF_START_MS = 1_000
|
const BACKOFF_START_MS = 1_000
|
||||||
const BACKOFF_MAX_MS = 30_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'
|
op: 'I' | 'U'
|
||||||
entity: 'question'
|
entity: 'question'
|
||||||
id: string
|
id: string
|
||||||
product_id: string
|
product_id: string
|
||||||
story_id: string
|
story_id: string | null
|
||||||
task_id: string | null
|
task_id: string | null
|
||||||
|
idea_id?: string | null
|
||||||
assignee_id: string | null
|
assignee_id: string | null
|
||||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
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 {
|
interface StateEvent {
|
||||||
questions: NotificationQuestion[]
|
questions: NotificationQuestion[]
|
||||||
}
|
}
|
||||||
|
|
@ -73,11 +104,44 @@ export function useNotificationsRealtime() {
|
||||||
|
|
||||||
source.addEventListener('message', (ev) => {
|
source.addEventListener('message', (ev) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(ev.data) as NotifyPayload
|
const payload = JSON.parse(ev.data) as AnyPayload
|
||||||
if (payload.entity !== 'question') return
|
|
||||||
// Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst
|
// M12 — idea-job events naar idea-store dispatchen.
|
||||||
// mee in de payload, dus we doen een mini-fetch via de same SSE's
|
if (isIdeaJobPayload(payload)) {
|
||||||
// initial-state on reconnect; hier voor MVP alleen status-handling).
|
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') {
|
if (payload.status === 'open') {
|
||||||
// Inkomende open vraag: we hebben de details nog niet — beste optie is
|
// Inkomende open vraag: we hebben de details nog niet — beste optie is
|
||||||
// herfetchen door opnieuw te verbinden, of via een API. Voor v1
|
// 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",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"yaml": "^2.8.4",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|
@ -21518,10 +21519,9 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"yaml": "^2.8.4",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -74,30 +74,58 @@ enum SprintStatus {
|
||||||
COMPLETED
|
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 {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
password_hash String
|
password_hash String
|
||||||
is_demo Boolean @default(false)
|
is_demo Boolean @default(false)
|
||||||
bio String? @db.VarChar(160)
|
bio String? @db.VarChar(160)
|
||||||
bio_detail String? @db.VarChar(2000)
|
bio_detail String? @db.VarChar(2000)
|
||||||
avatar_data Bytes?
|
avatar_data Bytes?
|
||||||
active_product_id String?
|
active_product_id String?
|
||||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||||
created_at DateTime @default(now())
|
idea_code_counter Int @default(0)
|
||||||
updated_at DateTime @updatedAt
|
created_at DateTime @default(now())
|
||||||
roles UserRole[]
|
updated_at DateTime @updatedAt
|
||||||
api_tokens ApiToken[]
|
roles UserRole[]
|
||||||
products Product[]
|
api_tokens ApiToken[]
|
||||||
todos Todo[]
|
products Product[]
|
||||||
product_members ProductMember[]
|
todos Todo[]
|
||||||
assigned_stories Story[] @relation("StoryAssignee")
|
ideas Idea[]
|
||||||
login_pairings LoginPairing[]
|
product_members ProductMember[]
|
||||||
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
|
assigned_stories Story[] @relation("StoryAssignee")
|
||||||
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
login_pairings LoginPairing[]
|
||||||
claude_jobs ClaudeJob[]
|
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
|
||||||
claude_workers ClaudeWorker[]
|
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
||||||
|
claude_jobs ClaudeJob[]
|
||||||
|
claude_workers ClaudeWorker[]
|
||||||
|
|
||||||
@@index([active_product_id])
|
@@index([active_product_id])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
|
|
@ -114,33 +142,33 @@ model UserRole {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ApiToken {
|
model ApiToken {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
token_hash String @unique
|
token_hash String @unique
|
||||||
label String?
|
label String?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
revoked_at DateTime?
|
revoked_at DateTime?
|
||||||
claimed_jobs ClaudeJob[]
|
claimed_jobs ClaudeJob[]
|
||||||
claude_worker ClaudeWorker?
|
claude_worker ClaudeWorker?
|
||||||
|
|
||||||
@@index([token_hash])
|
@@index([token_hash])
|
||||||
@@map("api_tokens")
|
@@map("api_tokens")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
name String
|
name String
|
||||||
code String? @db.VarChar(30)
|
code String? @db.VarChar(30)
|
||||||
description String?
|
description String?
|
||||||
repo_url String?
|
repo_url String?
|
||||||
definition_of_done String
|
definition_of_done String
|
||||||
auto_pr Boolean @default(false)
|
auto_pr Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
pbis Pbi[]
|
pbis Pbi[]
|
||||||
sprints Sprint[]
|
sprints Sprint[]
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
|
@ -150,6 +178,7 @@ model Product {
|
||||||
active_for_users User[] @relation("UserActiveProduct")
|
active_for_users User[] @relation("UserActiveProduct")
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
|
ideas Idea[]
|
||||||
|
|
||||||
@@unique([user_id, name])
|
@@unique([user_id, name])
|
||||||
@@unique([user_id, code])
|
@@unique([user_id, code])
|
||||||
|
|
@ -158,20 +187,21 @@ model Product {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Pbi {
|
model Pbi {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
code String @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status PbiStatus @default(READY)
|
status PbiStatus @default(READY)
|
||||||
pr_url String?
|
pr_url String?
|
||||||
pr_merged_at DateTime?
|
pr_merged_at DateTime?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
idea Idea?
|
||||||
|
|
||||||
@@unique([product_id, code])
|
@@unique([product_id, code])
|
||||||
@@index([product_id, priority, sort_order])
|
@@index([product_id, priority, sort_order])
|
||||||
|
|
@ -180,24 +210,24 @@ model Pbi {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Story {
|
model Story {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
|
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
|
||||||
pbi_id String
|
pbi_id String
|
||||||
product Product @relation(fields: [product_id], references: [id])
|
product Product @relation(fields: [product_id], references: [id])
|
||||||
product_id String
|
product_id String
|
||||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
sprint_id String?
|
sprint_id String?
|
||||||
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
|
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
|
||||||
assignee_id String?
|
assignee_id String?
|
||||||
code String @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
acceptance_criteria String?
|
acceptance_criteria String?
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status StoryStatus @default(OPEN)
|
status StoryStatus @default(OPEN)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
logs StoryLog[]
|
logs StoryLog[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
|
|
@ -244,29 +274,29 @@ model Sprint {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
story_id String
|
story_id String
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
sprint_id String?
|
sprint_id String?
|
||||||
code String @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
implementation_plan String?
|
implementation_plan String?
|
||||||
priority Int
|
priority Int
|
||||||
sort_order Float
|
sort_order Float
|
||||||
status TaskStatus @default(TO_DO)
|
status TaskStatus @default(TO_DO)
|
||||||
verify_only Boolean @default(false)
|
verify_only Boolean @default(false)
|
||||||
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
|
||||||
// Override product.repo_url for branch/worktree/push purposes. Set when
|
// Override product.repo_url for branch/worktree/push purposes. Set when
|
||||||
// a task targets a different repo than its parent product (e.g. an
|
// a task targets a different repo than its parent product (e.g. an
|
||||||
// MCP-server task tracked under the main product's PBI). Falls back to
|
// MCP-server task tracked under the main product's PBI). Falls back to
|
||||||
// product.repo_url when null.
|
// product.repo_url when null.
|
||||||
repo_url String?
|
repo_url String?
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
claude_questions ClaudeQuestion[]
|
claude_questions ClaudeQuestion[]
|
||||||
claude_jobs ClaudeJob[]
|
claude_jobs ClaudeJob[]
|
||||||
|
|
||||||
|
|
@ -283,8 +313,11 @@ model ClaudeJob {
|
||||||
user_id String
|
user_id String
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
|
||||||
task_id String
|
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)
|
status ClaudeJobStatus @default(QUEUED)
|
||||||
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
||||||
claimed_by_token_id String?
|
claimed_by_token_id String?
|
||||||
|
|
@ -304,20 +337,21 @@ model ClaudeJob {
|
||||||
|
|
||||||
@@index([user_id, status])
|
@@index([user_id, status])
|
||||||
@@index([task_id, status])
|
@@index([task_id, status])
|
||||||
|
@@index([idea_id, status])
|
||||||
@@index([status, claimed_at])
|
@@index([status, claimed_at])
|
||||||
@@index([status, finished_at])
|
@@index([status, finished_at])
|
||||||
@@map("claude_jobs")
|
@@map("claude_jobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClaudeWorker {
|
model ClaudeWorker {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
||||||
token_id String
|
token_id String
|
||||||
product_id String?
|
product_id String?
|
||||||
started_at DateTime @default(now())
|
started_at DateTime @default(now())
|
||||||
last_seen_at DateTime @default(now())
|
last_seen_at DateTime @default(now())
|
||||||
|
|
||||||
@@unique([token_id])
|
@@unique([token_id])
|
||||||
@@index([user_id, last_seen_at])
|
@@index([user_id, last_seen_at])
|
||||||
|
|
@ -338,23 +372,64 @@ model ProductMember {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Todo {
|
model Todo {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
||||||
product_id String?
|
product_id String?
|
||||||
title String
|
title String
|
||||||
description String? @db.VarChar(2000)
|
description String? @db.VarChar(2000)
|
||||||
done Boolean @default(false)
|
done Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
@@index([user_id, done, archived])
|
@@index([user_id, done, archived])
|
||||||
@@index([user_id, product_id])
|
@@index([user_id, product_id])
|
||||||
@@map("todos")
|
@@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 {
|
model LoginPairing {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
secret_hash String
|
secret_hash String
|
||||||
|
|
@ -375,26 +450,29 @@ model LoginPairing {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ClaudeQuestion {
|
model ClaudeQuestion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
story_id String
|
story_id String?
|
||||||
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
||||||
task_id String?
|
task_id String?
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||||
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
|
idea_id String?
|
||||||
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
asked_by String // user_id van token-houder (= Claude-token)
|
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
|
||||||
question String @db.Text
|
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
||||||
options Json? // string[] voor multi-choice; null voor free-text
|
asked_by String // user_id van token-houder (= Claude-token)
|
||||||
status String // 'open' | 'answered' | 'cancelled' | 'expired'
|
question String @db.Text
|
||||||
answer String? @db.Text
|
options Json? // string[] voor multi-choice; null voor free-text
|
||||||
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
|
status String // 'open' | 'answered' | 'cancelled' | 'expired'
|
||||||
answered_by String?
|
answer String? @db.Text
|
||||||
answered_at DateTime?
|
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
|
||||||
created_at DateTime @default(now())
|
answered_by String?
|
||||||
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
|
answered_at DateTime?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
|
||||||
|
|
||||||
@@index([story_id, status])
|
@@index([story_id, status])
|
||||||
|
@@index([idea_id, status])
|
||||||
@@index([product_id, status])
|
@@index([product_id, status])
|
||||||
@@index([status, expires_at])
|
@@index([status, expires_at])
|
||||||
@@map("claude_questions")
|
@@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 { unsealData } from 'iron-session'
|
||||||
import { sessionOptions, type SessionData } from '@/lib/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 authRoutes = ['/login', '/register']
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
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