Files
projectbrain-Public/backend/src/routes/checks.ts
T

466 lines
13 KiB
TypeScript

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 = <TSchema extends z.ZodTypeAny>(
schema: TSchema,
request: FastifyRequest,
reply: FastifyReply,
): z.output<TSchema> | undefined => {
const parsed = schema.safeParse(request.params);
if (!parsed.success) {
sendValidationError(request, reply, parsed.error.issues);
return undefined;
}
return parsed.data;
};
const parseQuery = <TSchema extends z.ZodTypeAny>(
schema: TSchema,
request: FastifyRequest,
reply: FastifyReply,
): z.output<TSchema> | 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 = <T extends string>(
values: string[],
allowed: readonly T[],
): T[] => values.filter((value): value is T => allowed.includes(value as T));
const sortByField = <T extends Record<string, unknown>>(
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<typeof checksQuerySchema>;
type PaginationQuery = z.output<typeof paginationQuerySchema>;
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<unknown>(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<ReviewMutationResponseDto>(
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<ReviewMutationResponseDto>(
response,
getRequestId(request),
);
setIdempotentResponse(cacheKey, { statusCode: 200, body });
return reply.status(200).send(body);
},
);
};