api: REST endpoints for ideas (M12 T-500)
- app/api/ideas/route.ts: GET (list with archived/product_id/status filters, user_id-scope), POST (creates DRAFT with auto IDEA-NNN code, 201) - app/api/ideas/[id]/route.ts: GET (idea + recent logs), PATCH (ideaUpdateSchema, isIdeaEditable guard) - lib/idea-dto.ts: API projection — converts Prisma row → DTO with lowercase status + has_grill_md/has_plan_md flags (md content excluded from list payloads, fetch via dedicated download action) Auth: session OR API-token via authenticateApiRequest. Strict user_id scope (no productAccessFilter — Idee is privé per Q8). 404 (not 403) for foreign-user reads to prevent enumeration. Tests: 13 cases (auth-401, demo-403, validation-422, malformed-400, not-found-404, status-mismatch-422, filter param round-trip, DTO shape). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6904de9f2b
commit
4b234dc300
4 changed files with 428 additions and 0 deletions
194
__tests__/api/ideas.test.ts
Normal file
194
__tests__/api/ideas.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
product: { findFirst: vi.fn() },
|
||||||
|
idea: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
ideaLog: { findMany: vi.fn() },
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/api-auth', () => ({
|
||||||
|
authenticateApiRequest: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/idea-code-server', () => ({
|
||||||
|
nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route'
|
||||||
|
import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route'
|
||||||
|
|
||||||
|
type M = {
|
||||||
|
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||||
|
idea: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||||
|
ideaLog: { findMany: ReturnType<typeof vi.fn> }
|
||||||
|
$transaction: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
const m = prisma as unknown as M
|
||||||
|
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
const NOW = new Date('2026-05-04T19:00:00Z')
|
||||||
|
|
||||||
|
const IDEA_ROW = {
|
||||||
|
id: 'idea-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
code: 'IDEA-001',
|
||||||
|
title: 'Plant-watering reminder',
|
||||||
|
description: null,
|
||||||
|
status: 'DRAFT' as const,
|
||||||
|
product_id: null,
|
||||||
|
product: null,
|
||||||
|
pbi: null,
|
||||||
|
pbi_id: null,
|
||||||
|
archived: false,
|
||||||
|
grill_md: null,
|
||||||
|
plan_md: null,
|
||||||
|
created_at: NOW,
|
||||||
|
updated_at: NOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request {
|
||||||
|
return new Request(`http://localhost${url}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||||
|
m.$transaction.mockImplementation(async (arg: unknown) => {
|
||||||
|
if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m)
|
||||||
|
return arg
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/ideas', () => {
|
||||||
|
it('returns user ideas (DTO shape)', async () => {
|
||||||
|
m.idea.findMany.mockResolvedValueOnce([IDEA_ROW])
|
||||||
|
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.ideas).toHaveLength(1)
|
||||||
|
expect(body.ideas[0]).toMatchObject({
|
||||||
|
id: 'idea-1',
|
||||||
|
code: 'IDEA-001',
|
||||||
|
status: 'draft',
|
||||||
|
has_grill_md: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unauthenticated', async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 })
|
||||||
|
const res = await getIdeas(makeRequest('GET', '/api/ideas'))
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by archived=false param', async () => {
|
||||||
|
m.idea.findMany.mockResolvedValueOnce([])
|
||||||
|
await getIdeas(makeRequest('GET', '/api/ideas?archived=false'))
|
||||||
|
expect(m.idea.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ archived: false, user_id: 'user-1' }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /api/ideas', () => {
|
||||||
|
it('creates idea and returns 201', async () => {
|
||||||
|
m.idea.create.mockResolvedValueOnce(IDEA_ROW)
|
||||||
|
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' }))
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects demo with 403', async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
|
||||||
|
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' }))
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty title with 422', async () => {
|
||||||
|
const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' }))
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed JSON with 400', async () => {
|
||||||
|
const req = new Request('http://localhost/api/ideas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' },
|
||||||
|
body: 'not-json',
|
||||||
|
})
|
||||||
|
const res = await postIdea(req)
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 when product_id refers to a foreign product', async () => {
|
||||||
|
m.product.findFirst.mockResolvedValueOnce(null)
|
||||||
|
const res = await postIdea(
|
||||||
|
makeRequest('POST', '/api/ideas', {
|
||||||
|
title: 'x',
|
||||||
|
product_id: 'cmohrysyj0000rd17clnjy4tc',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /api/ideas/[id]', () => {
|
||||||
|
it('returns idea + logs', async () => {
|
||||||
|
m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW)
|
||||||
|
m.ideaLog.findMany.mockResolvedValueOnce([
|
||||||
|
{ id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW },
|
||||||
|
])
|
||||||
|
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
||||||
|
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.idea).toMatchObject({ id: 'idea-1' })
|
||||||
|
expect(body.logs).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 (not 403) for foreign user — anti-enumeration', async () => {
|
||||||
|
m.idea.findFirst.mockResolvedValueOnce(null)
|
||||||
|
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
||||||
|
const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PATCH /api/ideas/[id]', () => {
|
||||||
|
const ctx = { params: Promise.resolve({ id: 'idea-1' }) }
|
||||||
|
|
||||||
|
it('updates editable idea', async () => {
|
||||||
|
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' })
|
||||||
|
m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' })
|
||||||
|
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks demo with 403', async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true })
|
||||||
|
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks update on PLANNED with 422', async () => {
|
||||||
|
m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' })
|
||||||
|
const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx)
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
})
|
||||||
|
})
|
||||||
91
app/api/ideas/[id]/route.ts
Normal file
91
app/api/ideas/[id]/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// Per-idea REST endpoints (M12). user_id-strict scope, 404 (niet 403) bij
|
||||||
|
// foreign user om enumeratie te vermijden.
|
||||||
|
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { ideaUpdateSchema } from '@/lib/schemas/idea'
|
||||||
|
import { isIdeaEditable } from '@/lib/idea-status'
|
||||||
|
import { ideaToDto } from '@/lib/idea-dto'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request, ctx: RouteContext) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await ctx.params
|
||||||
|
|
||||||
|
const idea = await prisma.idea.findFirst({
|
||||||
|
where: { id, user_id: auth.userId },
|
||||||
|
include: {
|
||||||
|
product: { select: { id: true, name: true, repo_url: true } },
|
||||||
|
pbi: { select: { id: true, code: true, title: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!idea) {
|
||||||
|
return Response.json({ error: 'Idee niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recente logs (max 50) — handig voor MCP tools die context willen ophalen.
|
||||||
|
const logs = await prisma.ideaLog.findMany({
|
||||||
|
where: { idea_id: id },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
select: { id: true, type: true, content: true, metadata: true, created_at: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ idea: ideaToDto(idea), logs })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, ctx: RouteContext) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
if (auth.isDemo) {
|
||||||
|
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await ctx.params
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const parsed = ideaUpdateSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = await prisma.idea.findFirst({
|
||||||
|
where: { id, user_id: auth.userId },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
})
|
||||||
|
if (!idea) {
|
||||||
|
return Response.json({ error: 'Idee niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (!isIdeaEditable(idea.status)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `Idee niet bewerkbaar in status ${idea.status}` },
|
||||||
|
{ status: 422 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.idea.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||||
|
...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
|
||||||
|
...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}),
|
||||||
|
},
|
||||||
|
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ idea: ideaToDto(updated) })
|
||||||
|
}
|
||||||
94
app/api/ideas/route.ts
Normal file
94
app/api/ideas/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// REST endpoints voor de Idee-entity (M12).
|
||||||
|
// - Strikt user_id-only — geen productAccessFilter.
|
||||||
|
// - Auth via session OF API-token (zelfde patroon als /api/todos).
|
||||||
|
// - Demo blokkeert POST/PATCH/DELETE (proxy.ts laag + 403 hier als second-line).
|
||||||
|
|
||||||
|
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { ideaCreateSchema } from '@/lib/schemas/idea'
|
||||||
|
import { ideaStatusFromApi, ideaStatusToApi } from '@/lib/idea-status'
|
||||||
|
import { nextIdeaCode } from '@/lib/idea-code-server'
|
||||||
|
import { ideaToDto } from '@/lib/idea-dto'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const archivedParam = url.searchParams.get('archived')
|
||||||
|
const productIdParam = url.searchParams.get('product_id')
|
||||||
|
const statusParam = url.searchParams.get('status')
|
||||||
|
|
||||||
|
const archived =
|
||||||
|
archivedParam === 'true' ? true : archivedParam === 'false' ? false : undefined
|
||||||
|
const status = statusParam ? ideaStatusFromApi(statusParam) ?? undefined : undefined
|
||||||
|
|
||||||
|
const ideas = await prisma.idea.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: auth.userId,
|
||||||
|
...(archived !== undefined ? { archived } : {}),
|
||||||
|
...(productIdParam ? { product_id: productIdParam } : {}),
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
},
|
||||||
|
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({ ideas: ideas.map(ideaToDto) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const auth = await authenticateApiRequest(request)
|
||||||
|
if ('error' in auth) {
|
||||||
|
return Response.json({ error: auth.error }, { status: auth.status })
|
||||||
|
}
|
||||||
|
if (auth.isDemo) {
|
||||||
|
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await request.json()
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
|
||||||
|
}
|
||||||
|
const parsed = ideaCreateSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionele product-binding: alleen toelaten als gebruiker eigenaar/member is.
|
||||||
|
if (parsed.data.product_id) {
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: parsed.data.product_id, user_id: auth.userId, archived: false },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) {
|
||||||
|
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = auth.userId
|
||||||
|
const idea = await prisma.$transaction(async (tx) => {
|
||||||
|
const code = await nextIdeaCode(userId, tx)
|
||||||
|
return tx.idea.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
product_id: parsed.data.product_id ?? null,
|
||||||
|
code,
|
||||||
|
title: parsed.data.title,
|
||||||
|
description: parsed.data.description ?? null,
|
||||||
|
status: 'DRAFT',
|
||||||
|
},
|
||||||
|
include: { product: { select: { id: true, name: true, repo_url: true } } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{ idea: { ...ideaToDto(idea), status: ideaStatusToApi(idea.status) } },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}
|
||||||
49
lib/idea-dto.ts
Normal file
49
lib/idea-dto.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// API-projection voor Idea — converteert Prisma-row naar het externe contract.
|
||||||
|
// Belangrijk: status wordt naar lowercase API-string vertaald (zelfde patroon
|
||||||
|
// als TaskStatus / StoryStatus / PbiStatus elders in de codebase).
|
||||||
|
|
||||||
|
import { ideaStatusToApi } from '@/lib/idea-status'
|
||||||
|
|
||||||
|
import type { Idea, IdeaStatus, Product } from '@prisma/client'
|
||||||
|
|
||||||
|
type IdeaWithProduct = Idea & {
|
||||||
|
product: Pick<Product, 'id' | 'name' | 'repo_url'> | null
|
||||||
|
pbi?: { id: string; code: string; title: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdeaDto {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
status: ReturnType<typeof ideaStatusToApi>
|
||||||
|
product_id: string | null
|
||||||
|
product: { id: string; name: string; repo_url: string | null } | null
|
||||||
|
pbi_id: string | null
|
||||||
|
pbi?: { id: string; code: string; title: string } | null
|
||||||
|
archived: boolean
|
||||||
|
has_grill_md: boolean
|
||||||
|
has_plan_md: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ideaToDto(idea: IdeaWithProduct & { status: IdeaStatus }): IdeaDto {
|
||||||
|
return {
|
||||||
|
id: idea.id,
|
||||||
|
code: idea.code,
|
||||||
|
title: idea.title,
|
||||||
|
description: idea.description,
|
||||||
|
status: ideaStatusToApi(idea.status),
|
||||||
|
product_id: idea.product_id,
|
||||||
|
product: idea.product,
|
||||||
|
pbi_id: idea.pbi_id,
|
||||||
|
pbi: idea.pbi ?? null,
|
||||||
|
archived: idea.archived,
|
||||||
|
// Geen md-content in lijst-payloads (kan groot zijn) — enkel een vlag.
|
||||||
|
has_grill_md: idea.grill_md !== null,
|
||||||
|
has_plan_md: idea.plan_md !== null,
|
||||||
|
created_at: idea.created_at.toISOString(),
|
||||||
|
updated_at: idea.updated_at.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue