415 lines
16 KiB
TypeScript
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));
|
|
});
|
|
|
|
};
|