Merge pull request #91 from madhura68/feat/m12-ideas

M12 — Idea entity + Grill/Plan jobs
This commit is contained in:
Janpeter Visser 2026-05-05 11:58:25 +02:00 committed by GitHub
commit 2893573004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 5623 additions and 141 deletions

View 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 })
})
})

View 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
View 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)
})
})

View file

@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findMany: vi.fn() },
claudeQuestion: { findMany: vi.fn() },
idea: { findMany: vi.fn().mockResolvedValue([]) },
},
}))

View 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.

View 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)
})
})

View 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)
})
})

View 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)
})
})

View file

@ -30,6 +30,26 @@ beforeEach(() => {
})
describe('proxy demo-guard', () => {
it('demo + POST /api/ideas → 403 (M12)', async () => {
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
const req = makeRequest('POST', '/api/ideas', true)
const res = await proxy(req)
expect(res?.status).toBe(403)
})
it('demo + PATCH /api/ideas/abc → 403 (M12)', async () => {
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
const req = makeRequest('PATCH', '/api/ideas/abc', true)
const res = await proxy(req)
expect(res?.status).toBe(403)
})
it('demo + GET /api/ideas → passthrough (M12)', async () => {
const req = makeRequest('GET', '/api/ideas', true)
const res = await proxy(req)
expect(res?.status).not.toBe(403)
})
it('demo + POST /api/todos → 403', async () => {
mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true })
const req = makeRequest('POST', '/api/todos', true)

View 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
View 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>
}

View file

@ -241,6 +241,60 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo
return { success: true }
}
// M12: promote a Todo into a DRAFT Idea. Anders dan Todo→PBI/Story (die de
// todo deleteert) ARCHIVEREN we de todo hier — het idee houdt zelf de
// planningsgeschiedenis bij, en de archived todo bewaart het oorspronkelijke
// vertrekpunt.
export async function promoteTodoToIdeaAction(todoId: string): Promise<
{ success: true; idea_id: string; idea_code: string } | { error: string; code?: number }
> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 401 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
if (!todoId) return { error: 'todoId is verplicht', code: 422 }
const todo = await prisma.todo.findFirst({
where: { id: todoId, user_id: session.userId },
select: { id: true, title: true, description: true, product_id: true, archived: true },
})
if (!todo) return { error: 'Todo niet gevonden', code: 404 }
if (todo.archived) return { error: 'Todo is al gearchiveerd', code: 422 }
const userId = session.userId
// Lazy-import om dit server-only bestand niet te dwingen in een client bundle.
const { nextIdeaCode } = await import('@/lib/idea-code-server')
const idea = await prisma.$transaction(async (tx) => {
const code = await nextIdeaCode(userId, tx)
const created = await tx.idea.create({
data: {
user_id: userId,
product_id: todo.product_id,
code,
title: todo.title,
description: todo.description ?? null,
status: 'DRAFT',
},
select: { id: true, code: true },
})
await tx.todo.update({ where: { id: todoId }, data: { archived: true } })
await tx.ideaLog.create({
data: {
idea_id: created.id,
type: 'NOTE',
content: `Promoted from Todo ${todoId}`,
metadata: { source_todo_id: todoId },
},
})
return created
})
revalidatePath('/ideas')
revalidatePath('/todos')
return { success: true, idea_id: idea.id, idea_code: idea.code }
}
export async function updateRolesAction(roles: string[]) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }

View 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
View 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>
)
}

View 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
View 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 },
)
}

View file

@ -26,17 +26,49 @@ const CHANNEL = 'scrum4me_changes'
const HEARTBEAT_MS = 25_000
const HARD_CLOSE_MS = 240_000
interface NotifyPayload {
// Question-payloads: emitted by the notify_question_change trigger on
// claude_questions. story_id and idea_id are mutually exclusive (DB-level
// check-constraint added in M12).
interface QuestionPayload {
op: 'I' | 'U'
entity: 'task' | 'story' | 'question'
entity: 'question'
id: string
product_id: string
story_id?: string
story_id?: string | null
task_id?: string | null
idea_id?: string | null
assignee_id?: string | null
status?: string
}
// Idea-job-payloads: emitted by actions/ideas.ts (startGrillJobAction etc.)
// via prisma.$executeRaw pg_notify. Always carries user_id + idea_id + kind.
interface IdeaJobPayload {
type: 'claude_job_enqueued' | 'claude_job_status'
job_id: string
idea_id: string
user_id: string
product_id?: string | null
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
status: string
}
type NotifyPayload = QuestionPayload | IdeaJobPayload
function isQuestionPayload(p: NotifyPayload): p is QuestionPayload {
return 'entity' in p && p.entity === 'question'
}
function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload {
return (
'type' in p &&
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
'idea_id' in p &&
'kind' in p &&
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN')
)
}
export async function GET(request: NextRequest) {
const session = await getSession()
if (!session.userId) {
@ -53,6 +85,15 @@ export async function GET(request: NextRequest) {
})
const accessibleProductIds = new Set(products.map((p) => p.id))
// M12: idea-questions zijn strikt user_id-only (geen productAccessFilter).
// We pre-fetchen de user's idea-ids zodat we snel kunnen filteren op het
// SSE-pad — geen DB-call per event.
const userIdeas = await prisma.idea.findMany({
where: { user_id: userId },
select: { id: true },
})
const accessibleIdeaIds = new Set(userIdeas.map((i) => i.id))
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
if (!directUrl) {
return Response.json(
@ -115,7 +156,24 @@ export async function GET(request: NextRequest) {
} catch {
return
}
if (payload.entity !== 'question') return
if (isIdeaJobPayload(payload)) {
// M12: idea-jobs zijn user-scoped, niet product-scoped.
if (payload.user_id !== userId) return
enqueue(`data: ${msg.payload}\n\n`)
return
}
if (!isQuestionPayload(payload)) return
// Idea-question: alleen voor de eigenaar van het idee.
if (payload.idea_id) {
if (!accessibleIdeaIds.has(payload.idea_id)) return
enqueue(`data: ${msg.payload}\n\n`)
return
}
// Story-question: bestaande product-access-check.
if (!accessibleProductIds.has(payload.product_id)) return
enqueue(`data: ${msg.payload}\n\n`)
})
@ -132,6 +190,9 @@ export async function GET(request: NextRequest) {
status: 'open',
expires_at: { gt: new Date() },
product_id: { in: products.map((p) => p.id) },
// Skip idea-questions (story_id NULL) — story-questions only here.
// Narrowing happens in the flatMap below — Prisma 7 rejects
// `story_id: { not: null }` at runtime.
},
orderBy: { created_at: 'desc' },
take: 100,
@ -150,7 +211,9 @@ export async function GET(request: NextRequest) {
enqueue(
`event: state\ndata: ${JSON.stringify({
questions: openQuestions.map((q) => ({
questions: openQuestions.flatMap((q) => {
if (!q.story || q.story_id === null) return []
return [{
id: q.id,
product_id: q.product_id,
story_id: q.story_id,
@ -162,7 +225,8 @@ export async function GET(request: NextRequest) {
options: q.options,
created_at: q.created_at.toISOString(),
expires_at: q.expires_at.toISOString(),
})),
}]
}),
})}\n\n`,
)

View file

@ -41,7 +41,11 @@ type EntityPayload = {
type JobPayload = {
type: 'claude_job_enqueued' | 'claude_job_status'
job_id: string
task_id: string
task_id?: string | null
// M12: idea-jobs zetten kind + idea_id ipv task_id. Solo filtert die weg
// (idea-jobs horen op /api/realtime/notifications, niet op het Solo Paneel).
idea_id?: string | null
kind?: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
user_id: string
product_id: string
status: string
@ -77,6 +81,8 @@ function shouldEmit(
userId: string,
): boolean {
if (isJobPayload(payload)) {
// M12: skip idea-jobs (kind=IDEA_*) — die horen op /api/realtime/notifications.
if (payload.kind === 'IDEA_GRILL' || payload.kind === 'IDEA_MAKE_PLAN') return false
return payload.user_id === userId && payload.product_id === productId
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -28,6 +28,10 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
status: 'open',
expires_at: { gt: new Date() },
product_id: { in: productIds },
// Skip idea-questions (story_id NULL): they have a separate
// realtime channel and aren't shown in this product-scoped bell.
// Narrowing happens in the flatMap below — Prisma 7 rejects
// `story_id: { not: null }` at runtime.
},
orderBy: { created_at: 'desc' },
take: 100,
@ -44,19 +48,22 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
},
})
const initial: NotificationQuestion[] = openQuestions.map((q) => ({
id: q.id,
product_id: q.product_id,
story_id: q.story_id,
task_id: q.task_id,
story_code: q.story.code,
story_title: q.story.title,
assignee_id: q.story.assignee_id,
question: q.question,
options: Array.isArray(q.options) ? (q.options as string[]) : null,
created_at: q.created_at.toISOString(),
expires_at: q.expires_at.toISOString(),
}))
const initial: NotificationQuestion[] = openQuestions.flatMap((q) => {
if (!q.story || q.story_id === null) return []
return [{
id: q.id,
product_id: q.product_id,
story_id: q.story_id,
task_id: q.task_id,
story_code: q.story.code,
story_title: q.story.title,
assignee_id: q.story.assignee_id,
question: q.question,
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} />
}

View file

@ -140,6 +140,7 @@ export function NavBar({
)
: disabledSpan('Solo')}
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
{navLink('/ideas', 'Ideeën', pathname.startsWith('/ideas'))}
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
</nav>
</div>

View file

@ -3,6 +3,7 @@
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation'
import {
useReactTable,
getCoreRowModel,
@ -26,6 +27,7 @@ import {
archiveSelectedTodosAction,
promoteTodoToPbiAction,
promoteTodoToStoryAction,
promoteTodoToIdeaAction,
} from '@/actions/todos'
interface Todo {
@ -233,6 +235,60 @@ function PromoteStoryDialog({
)
}
// --- Promote to Idea dialog (M12 T-514) ---
// Geen extra inputs nodig — title/description komen uit de todo, en
// promoteTodoToIdeaAction archiveert de todo automatisch.
function PromoteIdeaDialog({
todo,
onClose,
}: { todo: Todo; onClose: () => void }) {
const router = useRouter()
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
const [pending, startTransition] = useTransition()
function handleConfirm() {
startTransition(async () => {
const r = await promoteTodoToIdeaAction(todo.id)
if ('error' in r) {
toast.error(r.error)
return
}
toast.success(`Idee aangemaakt (${r.idea_code})`)
onClose()
router.push(`/ideas/${r.idea_id}`)
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
<h2 className="font-medium text-foreground">Promoveer naar Idee</h2>
<p className="text-sm text-muted-foreground">
Maak een nieuw idee van &lsquo;<strong>{todo.title}</strong>&rsquo;. De Todo wordt
gearchiveerd; je kunt hem later terugvinden in de archief-filter.
</p>
<p className="text-xs text-muted-foreground">
Het idee start als <code>DRAFT</code>. Je kunt het daarna grillen, plannen, en
materialiseren tot een PBI.
</p>
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
Annuleren
</Button>
<Button onClick={handleConfirm} disabled={pending}>
{pending ? 'Bezig…' : 'Promoveren'}
</Button>
</div>
</div>
</div>
)
}
// --- Detail card ---
function TodoCard({
mode,
@ -243,6 +299,7 @@ function TodoCard({
onSuccess,
onPromotePbi,
onPromoteStory,
onPromoteIdea,
}: {
mode: 'idle' | 'create' | 'edit'
activeTodo: Todo | null
@ -252,6 +309,7 @@ function TodoCard({
onSuccess: () => void
onPromotePbi: (todo: Todo) => void
onPromoteStory: (todo: Todo) => void
onPromoteIdea: (todo: Todo) => void
}) {
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
@ -366,6 +424,9 @@ function TodoCard({
<div className="flex items-center gap-2">
{!isDemo && (
<>
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteIdea(activeTodo)}>
Idee
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
PBI
</Button>
@ -393,6 +454,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
const [mode, setMode] = useState<'idle' | 'create'>('idle')
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
const [promoteIdea, setPromoteIdea] = useState<Todo | null>(null)
const filtered = useMemo(() => {
if (selectedProductId === 'all') return todos
@ -608,6 +670,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
onSuccess={handleCancel}
onPromotePbi={setPromotePbi}
onPromoteStory={setPromoteStory}
onPromoteIdea={setPromoteIdea}
/>
{promotePbi && (
@ -616,6 +679,9 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
{promoteStory && (
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
)}
{promoteIdea && (
<PromoteIdeaDialog todo={promoteIdea} onClose={() => setPromoteIdea(null)} />
)}
</div>
)
}

View file

@ -25,6 +25,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|---|---|---|
| [AnswerModal Profiel](./specs/dialogs/answer-modal.md) | active | 2026-05-04 |
| [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.md) | active | 2026-05-04 |
| [IdeaDialog Profiel](./specs/dialogs/idea.md) | active | 2026-05-04 |
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 |
@ -43,6 +44,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
| [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 |
| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 |
| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 |
| [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — |
| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 |
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |

View file

@ -36,6 +36,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
| M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 ST-907 |
| M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 ST-1008 |
| M11: Claude vraagt, gebruiker antwoordt | Persistent vraag-antwoord-kanaal tussen Claude (MCP) en de actieve gebruiker | ST-1101 ST-1108 |
| M12: Ideeën & Grill/Plan jobs | Idee-entity tussen Todo en PBI; interactief grillen + deterministisch materialiseren | ST-1192 ST-1201 |
---
## Backlog
@ -755,6 +756,49 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru
---
### M12: Ideeën & Grill/Plan jobs
**Implementatieplan:** [docs/plans/M12-ideas.md](../plans/M12-ideas.md)
**Dialog-profiel:** [docs/specs/dialogs/idea.md](../specs/dialogs/idea.md)
Idee is een nieuw concept tussen Todo en PBI. Strikt user_id-only (privé), met
twee Claude-jobs: **Grill Me** (interactief vragen-stellen via MCP) en **Make
Plan** (single-pass yaml-frontmatter genereren). De **Materialiseer**-knop
parseert het plan deterministisch en creëert PBI + stories + taken.
- [x] **ST-1192** — DB-schema & migratie voor Idea (T-491, T-492, T-489)
- Idea-model + IdeaLog-model + 3 enums; ClaudeJob.task_id nullable + idea_id +
kind; ClaudeQuestion.story_id nullable + idea_id; check-constraints +
pg_notify-trigger update
- [x] **ST-1193** — Lib + schemas + embedded prompts (T-493, T-494, T-495)
- zod-schemas, status-mapper + transition-guard, atomic code-generator,
yaml-frontmatter parser, embedded grill+make-plan prompts
- [x] **ST-1194** — Server actions + Todo→Idea promotie (T-496..T-499)
- CRUD, md-edit, job-triggers, materialize, relink, promoteTodoToIdeaAction
- [x] **ST-1195** — REST API + proxy demo-laag (T-500, T-501)
- /api/ideas + /api/ideas/[id]; demo-403 via proxy.ts catch-all
- [x] **ST-1196** — Realtime SSE + idea-store (T-502, T-503)
- SSE-routing voor idea-events; Zustand idea-store; extension van bestaande
notifications-realtime hook
- [ ] **ST-1197** — MCP-server tools (extern: madhura68/scrum4me-mcp)
- get_idea_context, update_idea_grill_md, update_idea_plan_md, log_idea_decision;
uitbreiding ask_user_question/wait_for_job/update_job_status; Docker rebuild
- [x] **ST-1198** — UI lijstpagina + row-actions (T-507, T-508, T-509)
- /ideas pagina, IdeaList tabel met filters, IdeaRowActions met
disabled-rules per status, idea-status-badge helper
- [x] **ST-1199** — UI detail + dialog + tabs (T-510..T-513)
- /ideas/[id] met 4 tabs (Idee/Grill/Plan/Timeline); md-editor met
yaml-validate; timeline met UNION view; pbi-link-card; dialog-profiel doc
- [x] **ST-1200** — Promote-from-Todo + sidebar (T-514, T-515)
- "→ Idee" knop in TodoCard, PromoteIdeaDialog, "Ideeën" nav-entry
- [ ] **ST-1201** — End-to-end smoke + docs-update (T-516, T-517)
- Volledige flow doorlopen volgens M12-ideas.md verificatie-script;
docs/runbooks/mcp-integration.md uitbreiden voor IDEA_*-job-kinds
- Done when: docs/INDEX.md opnieuw gegenereerd, alle stories ✓, MCP-server
PR met passende versie-bump gedeployed
---
## v2 Backlog (na MVP)
- [ ] Uitnodigingsflow voor teams — e-mailuitnodiging of link-gebaseerd; nu kunnen alleen admins met toegang tot het systeem Developers toevoegen via gebruikersnaam

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 552 KiB

After

Width:  |  Height:  |  Size: 667 KiB

Before After
Before After

299
docs/plans/M12-ideas.md Normal file
View 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).

View file

@ -35,15 +35,35 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
- `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag
**Job queue — agent worker mode (M13):**
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg.
- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`.
- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator:
- `kind: 'TASK_IMPLEMENTATION'` (default) — payload met `implementation_plan`, `story`, `pbi`, `sprint`, `repo_url`
- `kind: 'IDEA_GRILL'` of `'IDEA_MAKE_PLAN'` — payload met `idea`, `product`, `repo_url`, en `prompt_text` (de embedded prompt uit `lib/idea-prompts/`)
Stale CLAIMED-jobs (>30min) worden eerst terug naar QUEUED gezet. Lege queue na block-time = klaar.
- `mcp__scrum4me__update_job_status` — agent rapporteert `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event. Bij `failed` voor `IDEA_GRILL`/`IDEA_MAKE_PLAN` wordt de idea-status automatisch op `GRILL_FAILED` resp. `PLAN_FAILED` gezet. Auth: Bearer-token moet matchen `claimed_by_token_id`.
**Idea-jobs (M12) — agent gedrag per kind:**
| Kind | Werkwijze | Eind-call |
|---|---|---|
| `IDEA_GRILL` | Lees `prompt_text` (embedded grill-prompt) + `idea.grill_md` als startpunt; itereer met `ask_user_question(idea_id=...)`/`get_question_answer`; log onderweg `log_idea_decision`; eindig met `update_idea_grill_md(markdown)` | `update_job_status('done')` |
| `IDEA_MAKE_PLAN` | Lees `prompt_text` (embedded make-plan-prompt) + `idea.grill_md` + repo-context. **Stel GEEN vragen** — single-pass output. Bouw plan in strict yaml-frontmatter format en eindig met `update_idea_plan_md(markdown)`. Server-side parser kan parse-fail → `PLAN_FAILED` | `update_job_status('done')` |
**MCP-tools — Idea-laag (M12):**
- `mcp__scrum4me__get_idea_context(idea_id)``{ idea, product, repo_url, grill_md_so_far, open_questions, prompt_text }`
- `mcp__scrum4me__update_idea_grill_md(idea_id, markdown)` — schrijft veld; status → `GRILLED`; logt `IdeaLog{GRILL_RESULT}`
- `mcp__scrum4me__update_idea_plan_md(idea_id, markdown)` — server-side `parsePlanMd`; ok → `PLAN_READY` + `IdeaLog{PLAN_RESULT}`; parse-fail → `PLAN_FAILED` + `IdeaLog{JOB_EVENT, errors}`
- `mcp__scrum4me__log_idea_decision(idea_id, type, content, metadata?)``type ∈ {DECISION, NOTE}`
- `mcp__scrum4me__ask_user_question` — geüpgrade contract: exact één van `story_id` óf `idea_id` (xor); idea-vragen zijn user-private (geen productAccessFilter).
## Batch-loop (verplichte agent-flow)
Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop:
1. `wait_for_job` aanroepen.
2. Job uitvoeren volgens het meegegeven `implementation_plan`.
2. Switch op `kind`:
- `TASK_IMPLEMENTATION`: voer uit volgens het meegegeven `implementation_plan` (zoals altijd — branch, code, commit, push, verify_task_against_plan).
- `IDEA_GRILL`: laad `prompt_text` als gids; gebruik `ask_user_question` / `get_question_answer` voor de Q&A-loop; eindig met `update_idea_grill_md`.
- `IDEA_MAKE_PLAN`: laad `prompt_text` + `idea.grill_md`; **stel geen vragen**; produceer strict yaml-frontmatter; eindig met `update_idea_plan_md`.
3. `update_job_status('done'|'failed')` aanroepen.
4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen.
5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap.

167
docs/specs/dialogs/idea.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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** (13 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"],
})
```

View 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`: 1200 chars, **verplicht**.
- `pbi.priority`, `story.priority`, `task.priority`: integer 14.
- 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.
- 26 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau).
- 25 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
View 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
View 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'
}

View file

@ -40,6 +40,9 @@ export async function getVerifyResultStats(
status: 'DONE' as const,
verify_result: { not: null as null },
finished_at: { gt: cutoff },
// Note: task_id can now be NULL on idea-jobs (M12). The toTopJob mapper
// filters them out via .filter(Boolean). Keeping a where-side filter
// (`task_id: { not: null }`) is rejected by Prisma 7 runtime.
}
const [grouped, rawEmpty, rawDivergent] = await Promise.all([
@ -82,7 +85,8 @@ export async function getVerifyResultStats(
.filter(r => countMap.has(r))
.map(r => ({ result: r, count: countMap.get(r)! }))
function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string }; product: { id: string; name: string } }): TopJob {
function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string } | null; product: { id: string; name: string } }): TopJob | null {
if (!j.task) return null
return {
jobId: j.id,
taskId: j.task.id,
@ -95,8 +99,8 @@ export async function getVerifyResultStats(
return {
counts,
topEmpty: rawEmpty.map(toTopJob),
topDivergent: rawDivergent.map(toTopJob),
topEmpty: rawEmpty.map(toTopJob).filter((j): j is TopJob => j !== null),
topDivergent: rawDivergent.map(toTopJob).filter((j): j is TopJob => j !== null),
}
}

View file

@ -26,6 +26,12 @@ const CONFIGS: Record<string, RateLimitConfig> = {
'log-story': { windowMs: 60_000, max: 60 },
'upload-avatar': { windowMs: 3_600_000, max: 20 },
'answer-question': { windowMs: 60_000, max: 30 },
// M12 — Idea entity (zie docs/plans/M12-ideas.md)
'create-idea': { windowMs: 60_000, max: 30 },
'edit-idea-md': { windowMs: 60_000, max: 60 }, // grill_md / plan_md edits
'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers
'materialize-idea': { windowMs: 60_000, max: 5 },
}
const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }

View file

@ -12,21 +12,52 @@
import { useEffect, useRef } from 'react'
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
import { useIdeaStore } from '@/stores/idea-store'
const BACKOFF_START_MS = 1_000
const BACKOFF_MAX_MS = 30_000
interface NotifyPayload {
// Question-payloads (M11 + M12). story_id en idea_id zijn mutually exclusive
// (DB-check-constraint). Voor story-questions blijft het pad onveranderd;
// idea-questions worden naar de idea-store doorgezet.
interface QuestionPayload {
op: 'I' | 'U'
entity: 'question'
id: string
product_id: string
story_id: string
story_id: string | null
task_id: string | null
idea_id?: string | null
assignee_id: string | null
status: 'open' | 'answered' | 'cancelled' | 'expired'
}
// Idea-job-payloads (M12). Komen uit actions/ideas.ts pg_notify.
interface IdeaJobPayload {
type: 'claude_job_enqueued' | 'claude_job_status'
job_id: string
idea_id: string
user_id: string
product_id?: string | null
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
status: string
error?: string
}
type AnyPayload = QuestionPayload | IdeaJobPayload
function isQuestionPayload(p: AnyPayload): p is QuestionPayload {
return 'entity' in p && p.entity === 'question'
}
function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload {
return (
'type' in p &&
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN')
)
}
interface StateEvent {
questions: NotificationQuestion[]
}
@ -73,11 +104,44 @@ export function useNotificationsRealtime() {
source.addEventListener('message', (ev) => {
try {
const payload = JSON.parse(ev.data) as NotifyPayload
if (payload.entity !== 'question') return
// Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst
// mee in de payload, dus we doen een mini-fetch via de same SSE's
// initial-state on reconnect; hier voor MVP alleen status-handling).
const payload = JSON.parse(ev.data) as AnyPayload
// M12 — idea-job events naar idea-store dispatchen.
if (isIdeaJobPayload(payload)) {
useIdeaStore.getState().handleIdeaJobEvent({
type: payload.type,
job_id: payload.job_id,
idea_id: payload.idea_id,
user_id: payload.user_id,
product_id: payload.product_id ?? null,
kind: payload.kind,
// The store-types narrow this; cast is safe because the server
// emits valid statuses.
status: payload.status as 'queued',
error: payload.error,
})
return
}
if (!isQuestionPayload(payload)) return
// M12 — idea-question events naar idea-store dispatchen.
if (payload.idea_id) {
useIdeaStore.getState().handleIdeaQuestionEvent({
op: payload.op,
entity: 'question',
id: payload.id,
product_id: payload.product_id,
story_id: null,
idea_id: payload.idea_id,
task_id: payload.task_id,
assignee_id: payload.assignee_id,
status: payload.status,
})
return
}
// Story-questions: bestaande bell-pad onveranderd.
if (payload.status === 'open') {
// Inkomende open vraag: we hebben de details nog niet — beste optie is
// herfetchen door opnieuw te verbinden, of via een API. Voor v1

53
lib/schemas/idea.ts Normal file
View 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
View file

@ -42,6 +42,7 @@
"sonner": "^1.7.4",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"yaml": "^2.8.4",
"zod": "^3.25.76",
"zustand": "^5.0.12"
},
@ -21518,10 +21519,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View file

@ -55,6 +55,7 @@
"sonner": "^1.7.4",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"yaml": "^2.8.4",
"zod": "^3.25.76",
"zustand": "^5.0.12"
},

View file

@ -74,30 +74,58 @@ enum SprintStatus {
COMPLETED
}
enum IdeaStatus {
DRAFT
GRILLING
GRILL_FAILED
GRILLED
PLANNING
PLAN_FAILED
PLAN_READY
PLANNED
}
enum ClaudeJobKind {
TASK_IMPLEMENTATION
IDEA_GRILL
IDEA_MAKE_PLAN
}
enum IdeaLogType {
DECISION
NOTE
GRILL_RESULT
PLAN_RESULT
STATUS_CHANGE
JOB_EVENT
}
model User {
id String @id @default(cuid())
username String @unique
email String? @unique
password_hash String
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
avatar_data Bytes?
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
api_tokens ApiToken[]
products Product[]
todos Todo[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
id String @id @default(cuid())
username String @unique
email String? @unique
password_hash String
is_demo Boolean @default(false)
bio String? @db.VarChar(160)
bio_detail String? @db.VarChar(2000)
avatar_data Bytes?
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
api_tokens ApiToken[]
products Product[]
todos Todo[]
ideas Idea[]
product_members ProductMember[]
assigned_stories Story[] @relation("StoryAssignee")
login_pairings LoginPairing[]
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
claude_jobs ClaudeJob[]
claude_workers ClaudeWorker[]
@@index([active_product_id])
@@map("users")
@ -114,33 +142,33 @@ model UserRole {
}
model ApiToken {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token_hash String @unique
label String?
created_at DateTime @default(now())
revoked_at DateTime?
claimed_jobs ClaudeJob[]
claude_worker ClaudeWorker?
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
token_hash String @unique
label String?
created_at DateTime @default(now())
revoked_at DateTime?
claimed_jobs ClaudeJob[]
claude_worker ClaudeWorker?
@@index([token_hash])
@@map("api_tokens")
}
model Product {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
name String
code String? @db.VarChar(30)
code String? @db.VarChar(30)
description String?
repo_url String?
definition_of_done String
auto_pr Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
auto_pr Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
pbis Pbi[]
sprints Sprint[]
stories Story[]
@ -150,6 +178,7 @@ model Product {
active_for_users User[] @relation("UserActiveProduct")
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
ideas Idea[]
@@unique([user_id, name])
@@unique([user_id, code])
@ -158,20 +187,21 @@ model Product {
}
model Pbi {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String @db.VarChar(30)
title String
description String?
priority Int
sort_order Float
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String @db.VarChar(30)
title String
description String?
priority Int
sort_order Float
status PbiStatus @default(READY)
pr_url String?
pr_merged_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
stories Story[]
idea Idea?
@@unique([product_id, code])
@@index([product_id, priority, sort_order])
@ -180,24 +210,24 @@ model Pbi {
}
model Story {
id String @id @default(cuid())
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade)
pbi_id String
product Product @relation(fields: [product_id], references: [id])
product Product @relation(fields: [product_id], references: [id])
product_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint Sprint? @relation(fields: [sprint_id], references: [id])
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?
code String @db.VarChar(30)
code String @db.VarChar(30)
title String
description String?
acceptance_criteria String?
priority Int
sort_order Float
status StoryStatus @default(OPEN)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
status StoryStatus @default(OPEN)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
logs StoryLog[]
tasks Task[]
claude_questions ClaudeQuestion[]
@ -244,29 +274,29 @@ model Sprint {
}
model Task {
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
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
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String?
code String @db.VarChar(30)
code String @db.VarChar(30)
title String
description String?
implementation_plan String?
priority Int
sort_order Float
status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
status TaskStatus @default(TO_DO)
verify_only Boolean @default(false)
verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL)
// Override product.repo_url for branch/worktree/push purposes. Set when
// 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
// product.repo_url when null.
repo_url String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
created_at DateTime @default(now())
updated_at DateTime @updatedAt
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
@ -283,8 +313,11 @@ model ClaudeJob {
user_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
task Task @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
status ClaudeJobStatus @default(QUEUED)
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
claimed_by_token_id String?
@ -304,20 +337,21 @@ model ClaudeJob {
@@index([user_id, status])
@@index([task_id, status])
@@index([idea_id, status])
@@index([status, claimed_at])
@@index([status, finished_at])
@@map("claude_jobs")
}
model ClaudeWorker {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
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
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
@@unique([token_id])
@@index([user_id, last_seen_at])
@ -338,23 +372,64 @@ model ProductMember {
}
model Todo {
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?
title String
description String? @db.VarChar(2000)
done Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
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?
title String
description String? @db.VarChar(2000)
done Boolean @default(false)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([user_id, done, archived])
@@index([user_id, product_id])
@@map("todos")
}
model Idea {
id String @id @default(cuid())
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
user_id String
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
product_id String?
code String @db.VarChar(30)
title String
description String? @db.VarChar(4000)
grill_md String? @db.Text
plan_md String? @db.Text
pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull)
pbi_id String? @unique
status IdeaStatus @default(DRAFT)
archived Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
questions ClaudeQuestion[]
jobs ClaudeJob[]
logs IdeaLog[]
@@unique([user_id, code])
@@index([user_id, archived, status])
@@index([user_id, product_id])
@@map("ideas")
}
model IdeaLog {
id String @id @default(cuid())
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String
type IdeaLogType
content String @db.Text
metadata Json?
created_at DateTime @default(now())
@@index([idea_id, created_at])
@@map("idea_logs")
}
model LoginPairing {
id String @id @default(cuid())
secret_hash String
@ -375,26 +450,29 @@ model LoginPairing {
}
model ClaudeQuestion {
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
task_id String?
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
asked_by String // user_id van token-houder (= Claude-token)
question String @db.Text
options Json? // string[] voor multi-choice; null voor free-text
status String // 'open' | 'answered' | 'cancelled' | 'expired'
answer String? @db.Text
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
answered_by String?
answered_at DateTime?
created_at DateTime @default(now())
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
id String @id @default(cuid())
story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String?
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
task_id String?
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
idea_id String?
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String // gedenormaliseerd uit story.product_id voor SSE-filter
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
asked_by String // user_id van token-houder (= Claude-token)
question String @db.Text
options Json? // string[] voor multi-choice; null voor free-text
status String // 'open' | 'answered' | 'cancelled' | 'expired'
answer String? @db.Text
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
answered_by String?
answered_at DateTime?
created_at DateTime @default(now())
expires_at DateTime // ingesteld door MCP-tool, default now() + 24h
@@index([story_id, status])
@@index([idea_id, status])
@@index([product_id, status])
@@index([status, expires_at])
@@map("claude_questions")

View file

@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
import { unsealData } from 'iron-session'
import { sessionOptions, type SessionData } from '@/lib/session'
const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo']
const protectedRoutes = ['/dashboard', '/products', '/todos', '/ideas', '/settings', '/solo']
const authRoutes = ['/login', '/register']
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])

182
stores/idea-store.ts Normal file
View 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 }
}),
}))