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/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/lib/idea-dto.ts b/lib/idea-dto.ts new file mode 100644 index 0000000..b32d14a --- /dev/null +++ b/lib/idea-dto.ts @@ -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 | null + pbi?: { id: string; code: string; title: string } | null +} + +export interface IdeaDto { + id: string + code: string + title: string + description: string | null + status: ReturnType + 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(), + } +}