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
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 },
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue