294 lines
8.4 KiB
TypeScript
294 lines
8.4 KiB
TypeScript
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 = <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 splitCsv = (value: string | undefined): string[] =>
|
|
value
|
|
?.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean) ?? [];
|
|
|
|
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 = 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<typeof featuresQuerySchema>;
|
|
type PaginationQuery = z.output<typeof paginationQuerySchema>;
|
|
|
|
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));
|
|
});
|
|
};
|