import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import type { AppEnv } from "../config/env.js"; import { convertBrainstormForProject, createBrainstormForProject, listBrainstormsForProject, updateBrainstormForProject } from "../repositories/brainstormRepository.js"; import { getRequestId } from "../plugins/requestId.js"; import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse } from "../utils/idempotency.js"; type BrainstormRouteOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"] }; const idSchema = z.string().min(1).regex(/^[A-Za-z0-9-]+$/); const projectParamsSchema = z.object({ projectId: idSchema }); const brainstormParamsSchema = z.object({ projectId: idSchema, brainstormId: idSchema }); const bodySchema = z.object({ title: z.string().min(2).max(200), prompt: z.string().min(1).max(8000), scope: z.string().min(1).max(80), mode: z.string().min(1).max(80), status: z.string().max(80).optional(), targetEntityId: z.string().max(200).optional(), targetEntityType: z.string().max(80).optional(), generatedBy: z.string().max(80).optional(), contextSummary: z.string().max(1000).optional(), ideas: z.array(z.unknown()).optional(), clusters: z.array(z.unknown()).optional(), outcomes: z.array(z.unknown()).optional(), selectedIdeaIds: z.array(z.string()).optional(), nextActions: z.array(z.string()).optional(), }); const patchSchema = bodySchema.partial(); const conversionTargets = ["feature", "documentation", "context_pack", "blueprint_node", "initiative"] as const; const convertParamsSchema = z.object({ projectId: idSchema, brainstormId: idSchema }); const convertBodySchema = z.object({ target: z.enum(conversionTargets) }); const sendValidationError = (request: FastifyRequest, reply: FastifyReply, validation: z.ZodIssue[]) => reply.status(400).send(errorEnvelope("VALIDATION_ERROR", "Request validation failed", getRequestId(request), validation.map((issue) => ({ field: issue.path.join("."), message: issue.message, code: issue.code })))); const parseParams = (schema: TSchema, request: FastifyRequest, reply: FastifyReply): z.output | undefined => { const parsed = schema.safeParse(request.params); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; const getRequiredIdempotencyKey = (request: FastifyRequest, reply: FastifyReply): string | undefined => { const rawKey = request.headers["idempotency-key"]; const idempotencyKey = Array.isArray(rawKey) ? rawKey[0] : rawKey; if (!idempotencyKey?.trim()) { reply.status(400).send(errorEnvelope("IDEMPOTENCY_KEY_REQUIRED", "Idempotency-Key header is required.", getRequestId(request))); return undefined; } return idempotencyKey; }; export const registerBrainstormRoutes = async (app: FastifyInstance, options: BrainstormRouteOptions = {}) => { const dataSource = options.dataSource ?? "fixture"; app.get("/api/projects/:projectId/brainstorms", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); return successEnvelope({ items: await listBrainstormsForProject(params.projectId) }, getRequestId(request)); }); app.post("/api/projects/:projectId/brainstorms", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = bodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); const body = successEnvelope(await createBrainstormForProject(params.projectId, parsed.data), getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); }); app.patch("/api/projects/:projectId/brainstorms/:brainstormId", async (request, reply) => { const params = parseParams(brainstormParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = patchSchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); const item = await updateBrainstormForProject(params.projectId, params.brainstormId, parsed.data); if (!item) return reply.status(404).send(errorEnvelope("BRAINSTORM_NOT_FOUND", `Brainstorm '${params.brainstormId}' was not found.`, getRequestId(request))); const body = successEnvelope(item, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }); app.post("/api/projects/:projectId/brainstorms/:brainstormId/convert", async (request, reply) => { const params = parseParams(convertParamsSchema, request, reply); if (!params) return reply; if (dataSource !== "database") return reply.status(501).send(errorEnvelope("DATABASE_MODE_REQUIRED", "Brainstorm conversion persistence is only available when PROJECTBRAIN_DATA_SOURCE=database.", getRequestId(request))); const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey(request.method, request.url, idempotencyKey); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsed = convertBodySchema.safeParse(request.body); if (!parsed.success) return sendValidationError(request, reply, parsed.error.issues); const result = await convertBrainstormForProject(params.projectId, params.brainstormId, parsed.data.target); if (!result) return reply.status(404).send(errorEnvelope("BRAINSTORM_NOT_FOUND", `Brainstorm '${params.brainstormId}' was not found.`, getRequestId(request))); const body = successEnvelope(result, getRequestId(request)); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }); };