import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { applyCheckFilters, applyReviewAction, checkFilterValues, convertCheck, findCheck, listChecksForProject, toReviewBoard, } from "../fixtures/checkFixtures.js"; import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; import { findProject } from "../fixtures/projectFixtures.js"; import { getRequestId } from "../plugins/requestId.js"; import type { ApprovalState, CheckCategory, CheckItemDto, CheckListFilters, CheckPipelineStage, CheckSeverity, CheckStatus, ConvertTarget, ReviewAction, ReviewMutationResponseDto, } from "../types/api.js"; import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; import { getIdempotencyKey, getIdempotentResponse, setIdempotentResponse, } from "../utils/idempotency.js"; const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); const projectParamsSchema = z.object({ projectId: idSchema, }); const checkParamsSchema = z.object({ projectId: idSchema, checkId: idSchema, }); const sourceParamsSchema = z.object({ projectId: idSchema, sourceId: idSchema, }); const paginationQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().positive().max(100).default(25), cursor: z.string().optional(), sort: z.string().optional(), }); const checksQuerySchema = paginationQuerySchema.extend({ status: z.string().optional(), severity: z.string().optional(), source: z.string().optional(), confidence: z.coerce.number().int().min(0).max(100).optional(), approvalState: z.enum(["pending", "approved", "rejected", "blocked"]).optional(), category: z.string().optional(), }); const reviewBodySchema = z.object({ action: z.string().optional(), reason: z.string().optional(), targetEntityId: z.string().optional(), }); const convertBodySchema = z.object({ target: z.string().optional(), title: z.string().optional(), reason: z.string().optional(), }); const reviewActions = ["accept", "reject", "ignore", "merge"] as const; const convertTargets = ["bug", "task", "memory_rule", "decision"] as const; 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 parseQuery = ( schema: TSchema, request: FastifyRequest, reply: FastifyReply, ): z.output | undefined => { const parsed = schema.safeParse(request.query); if (!parsed.success) { sendValidationError(request, reply, parsed.error.issues); return undefined; } return parsed.data; }; const sendProjectNotFound = ( request: FastifyRequest, reply: FastifyReply, projectId: string, ) => reply.status(404).send( errorEnvelope( "PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request), ), ); const ensureProject = ( request: FastifyRequest, reply: FastifyReply, projectId: string, ) => { const project = findProject(projectId); if (!project) { sendProjectNotFound(request, reply, projectId); return undefined; } return project; }; const splitCsv = (value: string | undefined): string[] => value ?.split(",") .map((item) => item.trim()) .filter(Boolean) ?? []; const filterKnownValues = ( values: string[], allowed: readonly T[], ): T[] => values.filter((value): value is T => allowed.includes(value as T)); const sortByField = >( items: T[], sort: string | undefined, ): T[] => { if (!sort) return items; const [field, direction = "asc"] = sort.split(":"); if (!field) return items; return [...items].sort((left, right) => { const leftValue = field === "confidence" ? (left.trust as { confidence?: number })?.confidence : left[field]; const rightValue = field === "confidence" ? (right.trust as { confidence?: number })?.confidence : right[field]; if (leftValue === rightValue) return 0; const comparison = String(leftValue ?? "").localeCompare( String(rightValue ?? ""), undefined, { numeric: true }, ); return direction === "desc" ? -comparison : comparison; }); }; type ChecksQuery = z.output; type PaginationQuery = z.output; const toFilters = (query: ChecksQuery): CheckListFilters => ({ ...(query.status ? { status: filterKnownValues( splitCsv(query.status), checkFilterValues.status, ) as CheckStatus[], } : {}), ...(query.severity ? { severity: filterKnownValues( splitCsv(query.severity), checkFilterValues.severity, ) as CheckSeverity[], } : {}), ...(query.source ? { source: filterKnownValues( splitCsv(query.source), checkFilterValues.source, ) as CheckPipelineStage[], } : {}), ...(query.category ? { category: filterKnownValues( splitCsv(query.category), checkFilterValues.category, ) as CheckCategory[], } : {}), ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), ...(query.approvalState ? { approvalState: query.approvalState as ApprovalState } : {}), ...(query.cursor ? { cursor: query.cursor } : {}), }); const toPaginationInput = ( query: PaginationQuery, filters: PaginationInput["filters"], ): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters, }); const sendCheckNotFound = ( request: FastifyRequest, reply: FastifyReply, checkId: string, ) => reply.status(404).send( errorEnvelope( "CHECK_NOT_FOUND", `Check '${checkId}' was not found.`, getRequestId(request), ), ); 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; }; const sendReplayOrUndefined = ( reply: FastifyReply, key: string, ): boolean => { const replay = getIdempotentResponse(key); if (!replay) return false; reply.status(replay.statusCode).send(replay.body); return true; }; export const registerCheckRoutes = async (app: FastifyInstance) => { app.get("/api/projects/:projectId/checks", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; const query = parseQuery(checksQuerySchema, request, reply); if (!query) return reply; const filters = toFilters(query); const checks = sortByField( applyCheckFilters(listChecksForProject(params.projectId), filters), query.sort, ); return successEnvelope( paginate(checks, toPaginationInput(query, filters)), getRequestId(request), ); }); app.get("/api/projects/:projectId/review-board", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; return successEnvelope( toReviewBoard(listChecksForProject(params.projectId)), getRequestId(request), ); }); app.get("/api/projects/:projectId/checks/:checkId", async (request, reply) => { const params = parseParams(checkParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; const check = findCheck(params.projectId, params.checkId); if (!check) return sendCheckNotFound(request, reply, params.checkId); return successEnvelope(check, getRequestId(request)); }); app.patch( "/api/projects/:projectId/checks/:sourceId/review", async (request, reply) => { const params = parseParams(sourceParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey( request.method, request.url, idempotencyKey, ); if (sendReplayOrUndefined(reply, cacheKey)) return reply; const parsedBody = reviewBodySchema.safeParse(request.body); if (!parsedBody.success) { sendValidationError(request, reply, parsedBody.error.issues); return reply; } const action = parsedBody.data.action as ReviewAction | undefined; if (!action || !reviewActions.includes(action)) { return reply.status(400).send( errorEnvelope( "REVIEW_ACTION_INVALID", "Review action must be accept, reject, ignore, or merge.", getRequestId(request), ), ); } if (action === "merge" && !parsedBody.data.targetEntityId?.trim()) { return reply.status(409).send( errorEnvelope( "REVIEW_ACTION_CONFLICT", "targetEntityId is required when action is merge.", getRequestId(request), ), ); } if (!findCheck(params.projectId, params.sourceId)) { return sendCheckNotFound(request, reply, params.sourceId); } const response = applyReviewAction( params.projectId, params.sourceId, action, parsedBody.data.reason, parsedBody.data.targetEntityId, ); if (!response) return sendCheckNotFound(request, reply, params.sourceId); const body = successEnvelope( response, getRequestId(request), ); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }, ); app.post( "/api/projects/:projectId/checks/:sourceId/convert", async (request, reply) => { const params = parseParams(sourceParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; const idempotencyKey = getRequiredIdempotencyKey(request, reply); if (!idempotencyKey) return reply; const cacheKey = getIdempotencyKey( request.method, request.url, idempotencyKey, ); if (sendReplayOrUndefined(reply, cacheKey)) return reply; const parsedBody = convertBodySchema.safeParse(request.body); if (!parsedBody.success) { sendValidationError(request, reply, parsedBody.error.issues); return reply; } const target = parsedBody.data.target as ConvertTarget | undefined; if (!target || !convertTargets.includes(target)) { return reply.status(400).send( errorEnvelope( "CONVERT_TARGET_INVALID", "Convert target must be bug, task, memory_rule, or decision.", getRequestId(request), ), ); } if (!findCheck(params.projectId, params.sourceId)) { return sendCheckNotFound(request, reply, params.sourceId); } const response = convertCheck( params.projectId, params.sourceId, target, parsedBody.data.title, parsedBody.data.reason, ); if (!response) { return reply.status(409).send( errorEnvelope( "CONVERSION_FAILED", `Conversion failed for check '${params.sourceId}'.`, getRequestId(request), ), ); } const body = successEnvelope( response, getRequestId(request), ); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }, ); };