Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,414 @@
|
||||
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));
|
||||
});
|
||||
|
||||
};
|
||||
Reference in new issue
Block a user