import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { paginate, type PaginationInput } from "../fixtures/featureFixtures.js"; import { findProject } from "../fixtures/projectFixtures.js"; import { applyRelationshipFilters, createRelationship, deleteRelationship, findRelationship, listRelationshipsForProject, } from "../fixtures/relationshipFixtures.js"; import { getRequestId } from "../plugins/requestId.js"; import type { RelationshipListFilters, RelationshipType, 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-Za-z0-9-]+$/); const projectParamsSchema = z.object({ projectId: idSchema }); const relationshipParamsSchema = z.object({ projectId: idSchema, relationshipId: 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 relationshipTypes = [ "depends_on", "conflicts_with", "duplicates", "supersedes", "belongs_to", "implements", "references", ] as const; const relationshipQuerySchema = paginationQuerySchema.extend({ from: z.string().optional(), to: z.string().optional(), type: z.string().optional(), confidence: z.coerce.number().optional(), strength: z.coerce.number().int().min(1).max(5).optional(), }); const createRelationshipBodySchema = z.object({ from: z.string().optional(), to: z.string().optional(), type: z.enum(relationshipTypes).optional(), rationale: z.string().optional(), strength: z.number().int().min(1).max(5).optional(), confidence: z.number().min(0).max(5).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 ensureProject = ( request: FastifyRequest, reply: FastifyReply, projectId: string, ) => { const project = findProject(projectId); if (!project) { reply.status(404).send( errorEnvelope( "PROJECT_NOT_FOUND", `Project '${projectId}' was not found.`, getRequestId(request), ), ); return undefined; } return project; }; 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 comparison = String(left[field] ?? "").localeCompare( String(right[field] ?? ""), undefined, { numeric: true }, ); return direction === "desc" ? -comparison : comparison; }); }; const toPaginationInput = ( query: z.output, filters: PaginationInput["filters"], ): PaginationInput => ({ page: query.page, pageSize: query.pageSize, cursor: query.cursor, sort: query.sort, filters, }); const toFilters = ( query: z.output, ): RelationshipListFilters => ({ ...(query.from ? { from: query.from } : {}), ...(query.to ? { to: query.to } : {}), ...(query.type ? { type: splitCsv(query.type) as RelationshipType[] } : {}), ...(query.confidence !== undefined ? { confidence: query.confidence } : {}), ...(query.strength !== undefined ? { strength: query.strength } : {}), ...(query.cursor ? { cursor: query.cursor } : {}), }); 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 sendRelationshipNotFound = ( request: FastifyRequest, reply: FastifyReply, relationshipId: string, ) => reply.status(404).send( errorEnvelope( "RELATIONSHIP_NOT_FOUND", `Relationship '${relationshipId}' was not found.`, getRequestId(request), ), ); export const registerRelationshipRoutes = async (app: FastifyInstance) => { app.get("/api/projects/:projectId/relationships", async (request, reply) => { const params = parseParams(projectParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; const query = parseQuery(relationshipQuerySchema, request, reply); if (!query) return reply; const filters = toFilters(query); const relationships = sortByField( applyRelationshipFilters( listRelationshipsForProject(params.projectId), filters, ), query.sort, ); return successEnvelope( paginate(relationships, toPaginationInput(query, filters)), getRequestId(request), ); }); app.get( "/api/projects/:projectId/relationships/:relationshipId", async (request, reply) => { const params = parseParams(relationshipParamsSchema, request, reply); if (!params) return reply; if (!ensureProject(request, reply, params.projectId)) return reply; const relationship = findRelationship(params.projectId, params.relationshipId); if (!relationship) { return sendRelationshipNotFound(request, reply, params.relationshipId); } return successEnvelope(relationship, getRequestId(request)); }, ); app.post("/api/projects/:projectId/relationships", async (request, reply) => { const params = parseParams(projectParamsSchema, 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); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const parsedBody = createRelationshipBodySchema.safeParse(request.body); if ( !parsedBody.success || !parsedBody.data.from?.trim() || !parsedBody.data.to?.trim() || !parsedBody.data.type || !parsedBody.data.rationale?.trim() ) { return reply.status(400).send( errorEnvelope( "RELATIONSHIP_VALIDATION_FAILED", "from, to, type and rationale are required to create a relationship.", getRequestId(request), ), ); } const body = successEnvelope( createRelationship(params.projectId, { ...parsedBody.data, from: parsedBody.data.from, to: parsedBody.data.to, type: parsedBody.data.type, rationale: parsedBody.data.rationale, }), getRequestId(request), ); setIdempotentResponse(cacheKey, { statusCode: 201, body }); return reply.status(201).send(body); }); app.delete( "/api/projects/:projectId/relationships/:relationshipId", async (request, reply) => { const params = parseParams(relationshipParamsSchema, 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, ); const replay = getIdempotentResponse(cacheKey); if (replay) return reply.status(replay.statusCode).send(replay.body); const relationship = deleteRelationship( params.projectId, params.relationshipId, ); if (!relationship) { return sendRelationshipNotFound(request, reply, params.relationshipId); } const body = successEnvelope( { success: true, message: `Relationship '${params.relationshipId}' deleted.`, updatedEntityId: params.relationshipId, affectedEntities: [params.relationshipId], }, getRequestId(request), ); setIdempotentResponse(cacheKey, { statusCode: 200, body }); return reply.status(200).send(body); }, ); };