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

415 lines
16 KiB
TypeScript

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<string, RepositoryEvidence>();
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 = <T extends { id: string }>(project: T, evidence: Map<string, RepositoryEvidence>) => ({
...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));
});
};