import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import type { AppEnv } from "../config/env.js"; import { findFeature, listFeatureGroupsForProject as listFixtureFeatureGroupsForProject, listFeaturesForProject as listFixtureFeaturesForProject, paginate, type PaginationInput, } from "../fixtures/featureFixtures.js"; import { findProject } from "../fixtures/projectFixtures.js"; import { getRequestId } from "../plugins/requestId.js"; import { listFeatureGroupsForProject as listDatabaseFeatureGroupsForProject, } from "../repositories/featureGroupsRepository.js"; import { getFeatureBySlugOrId, listFeaturesForProject as listDatabaseFeaturesForProject, projectExists as databaseProjectExists, } from "../repositories/featuresRepository.js"; import type { FeatureDto, FeatureStatus } from "../types/api.js"; import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); const projectParamsSchema = z.object({ projectId: idSchema, }); const featureParamsSchema = z.object({ projectId: idSchema, featureId: idSchema, }); const booleanQuerySchema = z.preprocess( (value) => { if (value === undefined || typeof value === "boolean") return value; if (typeof value !== "string") return undefined; const normalized = value.toLowerCase(); if (["true", "1", "yes", "on"].includes(normalized)) return true; if (["false", "0", "no", "off"].includes(normalized)) return false; return undefined; }, z.boolean().optional(), ); 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 featuresQuerySchema = paginationQuerySchema.extend({ status: z.string().optional(), confidence: z.coerce.number().int().min(0).max(100).optional(), hasEvidence: booleanQuerySchema, groupId: idSchema.optional(), search: z.string().optional(), }); 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 splitCsv = (value: string | undefined): string[] => value ?.split(",") .map((item) => item.trim()) .filter(Boolean) ?? []; 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 = left[field]; const rightValue = right[field]; if (leftValue === rightValue) return 0; const comparison = String(leftValue ?? "").localeCompare( String(rightValue ?? ""), undefined, { numeric: true }, ); return direction === "desc" ? -comparison : comparison; }); }; type FeatureQuery = z.output; type PaginationQuery = z.output; const applyFeatureFilters = ( features: FeatureDto[], query: FeatureQuery, ): FeatureDto[] => { const statuses = splitCsv(query.status) as FeatureStatus[]; const normalizedSearch = query.search?.trim().toLowerCase(); return features.filter((feature) => { if (statuses.length > 0 && !statuses.includes(feature.status)) return false; if (query.confidence !== undefined && feature.confidence !== query.confidence) { return false; } if (query.groupId !== undefined && feature.groupId !== query.groupId) { return false; } if ( query.hasEvidence !== undefined && (feature.evidenceIds.length > 0) !== query.hasEvidence ) { return false; } if ( normalizedSearch && !`${feature.name} ${feature.description}` .toLowerCase() .includes(normalizedSearch) ) { return false; } return true; }); }; const toPaginationInput = ( query: PaginationQuery, filters: PaginationInput["filters"], ): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters, }); const ensureProject = async ( request: FastifyRequest, reply: FastifyReply, projectId: string, useDatabase: boolean, ) => { const exists = useDatabase ? await databaseProjectExists(projectId) : Boolean(findProject(projectId)); if (!exists) { sendProjectNotFound(request, reply, projectId); return false; } return true; }; type FeatureRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; }; export const registerFeatureRoutes = async ( app: FastifyInstance, options: FeatureRoutesOptions = {}, ) => { const useDatabase = options.dataSource === "database"; app.get("/api/projects/:projectId/feature-groups", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { return reply; } const groups = useDatabase ? await listDatabaseFeatureGroupsForProject(params.projectId) : listFixtureFeatureGroupsForProject(params.projectId); return successEnvelope(groups ?? [], getRequestId(request)); }); app.get("/api/projects/:projectId/features", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { return reply; } const query = parseQuery(featuresQuerySchema, request, reply); if (!query) return reply; const filters = { ...(query.status ? { status: splitCsv(query.status) } : {}), ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), ...(query.hasEvidence !== undefined ? { hasEvidence: query.hasEvidence } : {}), ...(query.groupId ? { groupId: query.groupId } : {}), ...(query.search ? { search: query.search } : {}), ...(query.cursor ? { cursor: query.cursor } : {}), }; const sourceFeatures = useDatabase ? await listDatabaseFeaturesForProject(params.projectId) : listFixtureFeaturesForProject(params.projectId); const filteredFeatures = sortByField( applyFeatureFilters(sourceFeatures ?? [], query), query.sort, ); return successEnvelope( paginate(filteredFeatures, toPaginationInput(query, filters)), getRequestId(request), ); }); app.get("/api/projects/:projectId/features/:featureId", async (request, reply) => { const params = parseParams(featureParamsSchema, request, reply); if (!params) return reply; if (!(await ensureProject(request, reply, params.projectId, useDatabase))) { return reply; } const feature = useDatabase ? await getFeatureBySlugOrId(params.projectId, params.featureId) : findFeature(params.projectId, params.featureId); if (!feature) { return reply.status(404).send( errorEnvelope( "FEATURE_NOT_FOUND", `Feature '${params.featureId}' was not found.`, getRequestId(request), ), ); } return successEnvelope(feature, getRequestId(request)); }); };