import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { findProject, getWorkspaceRead, listProjects as listFixtureProjects, } from "../fixtures/projectFixtures.js"; import { getRequestId } from "../plugins/requestId.js"; import { createProject, getProjectBySlugOrId, getProjectDNA, getProjectWorkspace, listProjects as listDatabaseProjects, updateActiveContextPack, updateProject, } from "../repositories/projectsRepository.js"; import type { AppEnv } from "../config/env.js"; import { prisma } from "../db/prisma.js"; import { errorEnvelope, successEnvelope } from "../utils/envelope.js"; import { mergeProjectSetupState, projectSetupActiveContextPackId, projectSetupWorkspaceState, sanitizeProjectSetupState } from "../repositories/projectSetupState.js"; import { existingRepositoryFromProjectSetup, hasProjectRepositoryProvisioning, projectSlugFromInput, provisionProjectRepository, } from "../services/projectRepositoryProvisioning.js"; const idSchema = z.string().min(1).regex(/^[a-z0-9-]+$/); const projectParamsSchema = z.object({ projectId: idSchema, }); const projectDnaSchema = z.object({ vision: z.string().trim().min(1).max(4000), goals: z.array(z.string().trim().min(1).max(1000)).max(50), nonGoals: z.array(z.string().trim().min(1).max(1000)).max(50), targetAudience: z.string().trim().min(1).max(1000), principles: z.array(z.string().trim().min(1).max(1000)).max(50), successMetrics: z.array(z.object({ metric: z.string().trim().min(1).max(200), target: z.string().trim().min(1).max(500), targetValue: z.number().optional(), operator: z.enum([">", "<", ">=", "<=", "=", "between"]).optional(), })).max(50), maturityStage: z.enum(["Discovery", "Definition", "Execution", "Validation", "Operational"]), revision: z.number().int().positive(), updatedAt: z.string().datetime().optional(), }); const projectSetupStateSchema = z.record(z.string(), z.unknown()).refine( (value) => JSON.stringify(value).length <= 750_000, "Project setup state may not exceed 750 KB.", ); const createProjectBodySchema = z.object({ id: idSchema.optional(), name: z.string().trim().min(2).max(120), description: z.string().trim().min(8).max(2000), domain: z.string().trim().min(1).max(120).optional(), primaryGoal: z.string().trim().min(1).max(1000).optional(), targetAudience: z.string().trim().min(1).max(500).optional(), dna: projectDnaSchema.optional(), setupState: projectSetupStateSchema.optional(), }); const updateProjectBodySchema = z.object({ name: z.string().trim().min(2).max(120).optional(), description: z.string().trim().min(8).max(2000).optional(), domain: z.string().trim().min(1).max(120).optional(), status: z.enum(["active", "archived", "paused"]).optional(), }).refine((value) => Object.keys(value).length > 0, { message: "At least one project field must be provided.", }); const updateActiveContextPackBodySchema = z.object({ contextPackId: idSchema, }); 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 parseProjectParams = (request: FastifyRequest, reply: FastifyReply) => { const parsed = projectParamsSchema.safeParse(request.params); 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), ), ); type ProjectRoutesOptions = { dataSource?: AppEnv["PROJECTBRAIN_DATA_SOURCE"]; env?: AppEnv; }; type RepositoryEvidence = { repositoryLinkCount: number; readyRepositoryCount: number }; const repositoryEvidenceForProjects = async (projectSlugs: string[]) => { const evidence = new Map(); for (const slug of projectSlugs) evidence.set(slug, { repositoryLinkCount: 0, readyRepositoryCount: 0 }); if (!projectSlugs.length) return evidence; const links = await prisma.giteaRepositoryLink.findMany({ where: { project: { deletedAt: null, slug: { in: projectSlugs } } }, select: { syncStatus: true, project: { select: { slug: true } } }, }); for (const link of links) { const current = evidence.get(link.project.slug) ?? { repositoryLinkCount: 0, readyRepositoryCount: 0 }; current.repositoryLinkCount += 1; if (link.syncStatus === "ready") current.readyRepositoryCount += 1; evidence.set(link.project.slug, current); } return evidence; }; const withRepositoryEvidence = (project: T, evidence: Map) => ({ ...project, ...(evidence.get(project.id) ?? { repositoryLinkCount: 0, readyRepositoryCount: 0 }), }); export const registerProjectRoutes = async ( app: FastifyInstance, options: ProjectRoutesOptions = {}, ) => { const useDatabase = options.dataSource === "database"; app.get("/api/projects", async (request) => { if (!useDatabase) return successEnvelope(listFixtureProjects(), getRequestId(request)); const projects = await listDatabaseProjects(); const evidence = await repositoryEvidenceForProjects(projects.map((project) => project.id)); return successEnvelope(projects.map((project) => withRepositoryEvidence(project, evidence)), getRequestId(request)); }); app.post("/api/projects", async (request, reply) => { if (useDatabase && request.authUser?.role !== "owner") { return reply.status(403).send(errorEnvelope( "PROJECT_CREATION_FORBIDDEN", "Only the ProjectBrain owner can create a project and provision its Gitea repository.", getRequestId(request), )); } if (useDatabase && options.env?.NODE_ENV === "production" && (!options.env.PROJECTBRAIN_GITEA_URL || !options.env.PROJECTBRAIN_GITEA_TOKEN)) { return reply.status(503).send(errorEnvelope( "GITEA_NOT_CONFIGURED", "ProjectBrain kan geen project starten zolang de Gitea-verbinding niet geconfigureerd is.", getRequestId(request), )); } const parsed = createProjectBodySchema.safeParse(request.body); if (!parsed.success) { return sendValidationError(request, reply, parsed.error.issues); } if (!useDatabase) { const now = new Date().toISOString(); const slug = parsed.data.id ?? parsed.data.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); const setupState = sanitizeProjectSetupState(parsed.data.setupState); const setupWorkspace = projectSetupWorkspaceState(parsed.data.setupState); return reply.status(201).send( successEnvelope( mergeProjectSetupState(parsed.data.setupState, { id: slug, name: parsed.data.name, description: parsed.data.description, health: 100, healthStatus: "healthy", lastSync: now, domain: parsed.data.domain ?? "general", status: "active", createdAt: now, updatedAt: now, activeContextPackId: projectSetupActiveContextPackId(parsed.data.setupState), dna: { vision: parsed.data.dna?.vision ?? parsed.data.primaryGoal ?? parsed.data.description, goals: parsed.data.dna?.goals ?? (parsed.data.primaryGoal ? [parsed.data.primaryGoal] : []), nonGoals: parsed.data.dna?.nonGoals ?? [], targetAudience: parsed.data.dna?.targetAudience ?? parsed.data.targetAudience ?? "Delivery teams", principles: parsed.data.dna?.principles ?? [], successMetrics: parsed.data.dna?.successMetrics ?? [], maturityStage: parsed.data.dna?.maturityStage ?? "Discovery", revision: parsed.data.dna?.revision ?? 1, updatedAt: now, }, constraints: Array.isArray(setupState.constraints) ? setupState.constraints : [], principles: Array.isArray(setupState.principles) ? setupState.principles : [], initiatives: [], featureGroups: Array.isArray(setupState.featureGroups) ? setupState.featureGroups : [], memoryRules: Array.isArray(setupState.memoryRules) ? setupState.memoryRules : [], promptTemplates: Array.isArray(setupState.promptTemplates) ? setupState.promptTemplates : [], workspaceState: { ...setupWorkspace, projectId: slug, primaryObjective: typeof setupWorkspace?.primaryObjective === "string" ? setupWorkspace.primaryObjective : parsed.data.primaryGoal ?? parsed.data.description, focusAreas: Array.isArray(setupWorkspace?.focusAreas) ? setupWorkspace.focusAreas : [], openTasksCount: typeof setupWorkspace?.openTasksCount === "number" ? setupWorkspace.openTasksCount : 0, openBugCount: typeof setupWorkspace?.openBugCount === "number" ? setupWorkspace.openBugCount : 0, activeSection: typeof setupWorkspace?.activeSection === "string" ? setupWorkspace.activeSection : "workspace", updatedAt: now, notes: Array.isArray(setupWorkspace?.notes) ? setupWorkspace.notes : [], }, }), getRequestId(request), ), ); } const existingRepository = existingRepositoryFromProjectSetup(parsed.data.setupState); const desiredSlug = projectSlugFromInput(parsed.data.id, parsed.data.name); let project; try { project = await createProject(parsed.data); } catch (error) { if (error instanceof Error && error.message === "PROJECT_SLUG_ALREADY_EXISTS") { const existingProject = await getProjectBySlugOrId(desiredSlug); const sameCreateRequest = existingProject?.name === parsed.data.name.trim() && existingProject.description === parsed.data.description.trim(); const hasManagedProvisioning = !existingRepository && Boolean(options.env?.PROJECTBRAIN_GITEA_URL && options.env.PROJECTBRAIN_GITEA_TOKEN) && sameCreateRequest && await hasProjectRepositoryProvisioning(desiredSlug); if (hasManagedProvisioning) { project = existingProject; if (!project) throw error; } else { return reply.status(409).send( errorEnvelope( "VALIDATION_ERROR", "A project with this id or slug already exists.", getRequestId(request), [ { field: "id", message: "Choose a different project name or id.", code: "custom", }, ], ), ); } } else { throw error; } } if (!project) throw new Error("PROJECT_CREATE_FAILED"); const shouldProvisionRepository = !existingRepository && Boolean(options.env?.PROJECTBRAIN_GITEA_URL && options.env.PROJECTBRAIN_GITEA_TOKEN); if (shouldProvisionRepository) { try { await provisionProjectRepository(options.env!, project.id, `web:${request.authUser?.username ?? "owner"}`); } catch (error) { const code = error instanceof Error ? error.message : "GITEA_REPOSITORY_PROVISIONING_FAILED"; return reply.status(502).send(errorEnvelope( "GITEA_REPOSITORY_PROVISIONING_FAILED", "Het ProjectBrain-project is veilig opgeslagen, maar de Gitea-repository kon nog niet worden aangemaakt. Probeer opnieuw; de bestaande provisioning wordt idempotent hervat.", getRequestId(request), [{ field: "repository", message: code, code: "external_service" }], )); } } const evidence = await repositoryEvidenceForProjects([project.id]); return reply.status(201).send(successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request))); }); app.get("/api/projects/:projectId", async (request, reply) => { const params = parseProjectParams(request, reply); if (!params) return reply; if (useDatabase) { const project = await getProjectBySlugOrId(params.projectId); if (!project) return sendProjectNotFound(request, reply, params.projectId); const evidence = await repositoryEvidenceForProjects([project.id]); return successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request)); } const project = findProject(params.projectId); if (!project) return sendProjectNotFound(request, reply, params.projectId); return successEnvelope(project, getRequestId(request)); }); app.patch("/api/projects/:projectId", async (request, reply) => { const params = parseProjectParams(request, reply); if (!params) return reply; const parsed = updateProjectBodySchema.safeParse(request.body); if (!parsed.success) { return sendValidationError(request, reply, parsed.error.issues); } if (useDatabase) { const project = await updateProject(params.projectId, parsed.data); if (!project) return sendProjectNotFound(request, reply, params.projectId); const evidence = await repositoryEvidenceForProjects([project.id]); return successEnvelope(withRepositoryEvidence(project, evidence), getRequestId(request)); } const project = findProject(params.projectId); if (!project) return sendProjectNotFound(request, reply, params.projectId); return successEnvelope( { ...project, ...parsed.data, updatedAt: new Date().toISOString(), lastSync: new Date().toISOString(), }, getRequestId(request), ); }); app.get("/api/projects/:projectId/workspace", async (request, reply) => { const params = parseProjectParams(request, reply); if (!params) return reply; if (useDatabase) { const workspace = await getProjectWorkspace(params.projectId); if (!workspace) return sendProjectNotFound(request, reply, params.projectId); const evidence = await repositoryEvidenceForProjects([workspace.project.id]); return successEnvelope({ ...workspace, project: withRepositoryEvidence(workspace.project, evidence) }, getRequestId(request)); } const project = findProject(params.projectId); if (!project) return sendProjectNotFound(request, reply, params.projectId); return successEnvelope(getWorkspaceRead(project), getRequestId(request)); }); app.patch("/api/projects/:projectId/workspace/active-context-pack", async (request, reply) => { const params = parseProjectParams(request, reply); if (!params) return reply; const parsed = updateActiveContextPackBodySchema.safeParse(request.body); if (!parsed.success) { return sendValidationError(request, reply, parsed.error.issues); } if (useDatabase) { const workspace = await updateActiveContextPack(params.projectId, parsed.data); if (!workspace) return sendProjectNotFound(request, reply, params.projectId); const evidence = await repositoryEvidenceForProjects([workspace.project.id]); return successEnvelope({ ...workspace, project: withRepositoryEvidence(workspace.project, evidence) }, getRequestId(request)); } const project = findProject(params.projectId); if (!project) return sendProjectNotFound(request, reply, params.projectId); return successEnvelope( { ...getWorkspaceRead(project), activeContextPackId: parsed.data.contextPackId, workspaceState: project.workspaceState ? { ...project.workspaceState, activeContextPackId: parsed.data.contextPackId, updatedAt: new Date().toISOString(), } : undefined, }, getRequestId(request), ); }); app.get("/api/projects/:projectId/dna", async (request, reply) => { const params = parseProjectParams(request, reply); if (!params) return reply; if (useDatabase) { const dna = await getProjectDNA(params.projectId); if (!dna) return sendProjectNotFound(request, reply, params.projectId); return successEnvelope(dna, getRequestId(request)); } const project = findProject(params.projectId); if (!project) return sendProjectNotFound(request, reply, params.projectId); return successEnvelope(project.dna, getRequestId(request)); }); };