diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts new file mode 100644 index 0000000..525c56f --- /dev/null +++ b/__tests__/actions/ideas-crud.test.ts @@ -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; findFirst: ReturnType; update: ReturnType; delete: ReturnType } + ideaLog: { create: ReturnType } + claudeJob: { findFirst: ReturnType; create: ReturnType; update: ReturnType } + claudeWorker: { count: ReturnType } + pbi: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType } + story: { findMany: ReturnType; create: ReturnType } + task: { findMany: ReturnType; create: ReturnType } + $transaction: ReturnType + $executeRaw: ReturnType +} +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 }) + }) +}) diff --git a/__tests__/actions/todos-promote-idea.test.ts b/__tests__/actions/todos-promote-idea.test.ts new file mode 100644 index 0000000..7ddb169 --- /dev/null +++ b/__tests__/actions/todos-promote-idea.test.ts @@ -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; update: ReturnType } + idea: { create: ReturnType } + ideaLog: { create: ReturnType } + $transaction: ReturnType +} +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() + }) +}) diff --git a/__tests__/api/ideas.test.ts b/__tests__/api/ideas.test.ts new file mode 100644 index 0000000..448cc6b --- /dev/null +++ b/__tests__/api/ideas.test.ts @@ -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 } + idea: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } + ideaLog: { findMany: ReturnType } + $transaction: ReturnType +} +const m = prisma as unknown as M +const mockAuth = authenticateApiRequest as ReturnType + +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) + }) +}) diff --git a/__tests__/api/notifications-stream.test.ts b/__tests__/api/notifications-stream.test.ts index 53fc590..59fd1a8 100644 --- a/__tests__/api/notifications-stream.test.ts +++ b/__tests__/api/notifications-stream.test.ts @@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn() }, claudeQuestion: { findMany: vi.fn() }, + idea: { findMany: vi.fn().mockResolvedValue([]) }, }, })) diff --git a/__tests__/lib/idea-code.test.ts b/__tests__/lib/idea-code.test.ts new file mode 100644 index 0000000..f0a9150 --- /dev/null +++ b/__tests__/lib/idea-code.test.ts @@ -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. diff --git a/__tests__/lib/idea-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts new file mode 100644 index 0000000..30169aa --- /dev/null +++ b/__tests__/lib/idea-plan-parser.test.ts @@ -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) + }) +}) diff --git a/__tests__/lib/idea-schemas.test.ts b/__tests__/lib/idea-schemas.test.ts new file mode 100644 index 0000000..1514f5d --- /dev/null +++ b/__tests__/lib/idea-schemas.test.ts @@ -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) + }) +}) diff --git a/__tests__/lib/idea-status.test.ts b/__tests__/lib/idea-status.test.ts new file mode 100644 index 0000000..0dfc3dc --- /dev/null +++ b/__tests__/lib/idea-status.test.ts @@ -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) + }) +}) diff --git a/__tests__/proxy/demo-guard.test.ts b/__tests__/proxy/demo-guard.test.ts index f229a8f..1ae94a2 100644 --- a/__tests__/proxy/demo-guard.test.ts +++ b/__tests__/proxy/demo-guard.test.ts @@ -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) diff --git a/__tests__/stores/idea-store.test.ts b/__tests__/stores/idea-store.test.ts new file mode 100644 index 0000000..37d7413 --- /dev/null +++ b/__tests__/stores/idea-store.test.ts @@ -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') + }) +}) diff --git a/actions/ideas.ts b/actions/ideas.ts new file mode 100644 index 0000000..1ae5e47 --- /dev/null +++ b/actions/ideas.ts @@ -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 { + return prisma.claudeWorker.count({ + where: { + user_id: userId, + last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) }, + }, + }) +} + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +// Standaard error-shape voor consistente UI-rendering — zie ook actions/todos.ts. +type ActionResult = + | { 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> { + 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 { + 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 { + return setArchived(id, true) +} + +export async function unarchiveIdeaAction(id: string): Promise { + return setArchived(id, false) +} + +async function setArchived(id: string, archived: boolean): Promise { + 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 { + 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 { + 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 { + 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> { + 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> { + return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM) +} + +export async function startMakePlanJobAction(id: string): Promise> { + return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM) +} + +async function startIdeaJob( + id: string, + kind: ClaudeJobKind, + newStatus: IdeaStatus, + allowedFrom: IdeaStatus[], +): Promise> { + 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 { + 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> { + 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 { + 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 + +async function loadOwnedIdea( + id: string, + userId: string, + fields: S, +): Promise | 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 | null> +} diff --git a/actions/todos.ts b/actions/todos.ts index 7720eb4..02e4864 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -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' } diff --git a/app/(app)/ideas/[id]/page.tsx b/app/(app)/ideas/[id]/page.tsx new file mode 100644 index 0000000..0d25fb7 --- /dev/null +++ b/app/(app)/ideas/[id]/page.tsx @@ -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(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 ( + ({ + 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'} + /> + ) +} diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx new file mode 100644 index 0000000..142e376 --- /dev/null +++ b/app/(app)/ideas/page.tsx @@ -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(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 ( +
+
+

Ideeën

+

+ Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien. +

+
+ + ideaToDto(i))} + products={products} + isDemo={session.isDemo ?? false} + /> +
+ ) +} diff --git a/app/api/ideas/[id]/route.ts b/app/api/ideas/[id]/route.ts new file mode 100644 index 0000000..4c547c3 --- /dev/null +++ b/app/api/ideas/[id]/route.ts @@ -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) }) +} diff --git a/app/api/ideas/route.ts b/app/api/ideas/route.ts new file mode 100644 index 0000000..84d1ad7 --- /dev/null +++ b/app/api/ideas/route.ts @@ -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 }, + ) +} diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 907898a..834ebff 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -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`, ) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 0553cf6..e514797 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -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 } diff --git a/components/ideas/download-md-button.tsx b/components/ideas/download-md-button.tsx new file mode 100644 index 0000000..6e1a255 --- /dev/null +++ b/components/ideas/download-md-button.tsx @@ -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 ( + + ) +} diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx new file mode 100644 index 0000000..0e12ac8 --- /dev/null +++ b/components/ideas/idea-detail-layout.tsx @@ -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[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 ( +
+ {/* Breadcrumb / back-link */} + + + Alle ideeën + + + {/* Header */} +
+
+

{idea.code}

+

{idea.title}

+
+ + {badge.label} + + {idea.product ? ( + + {idea.product.name} + + + ) : ( + geen product + )} +
+
+ +
+ + {/* PBI-link card / Re-link banner bij PLANNED */} + + + {/* Tab-switcher */} + + + {/* Tab content */} + {tab === 'idee' && ( + + )} + {tab === 'grill' && ( + + )} + {tab === 'plan' && ( + + )} + {tab === 'timeline' && } +
+ ) +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ + setTitle(e.target.value)} + disabled={!editable || pending || submitting} + /> +
+
+ +