20a4ca08a5
Make workspace cwd optional to support repo-only workspaces that don't require a local directory. Refactor workspace resolution in heartbeat service to pass all workspace hints to adapters, add fallback logic when project workspaces have no valid local cwd, and improve workspace name derivation. Also adds limit param to heartbeat runs list endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
233 lines
6.8 KiB
TypeScript
233 lines
6.8 KiB
TypeScript
import { Router } from "express";
|
|
import type { Db } from "@paperclip/db";
|
|
import {
|
|
createProjectSchema,
|
|
createProjectWorkspaceSchema,
|
|
updateProjectSchema,
|
|
updateProjectWorkspaceSchema,
|
|
} from "@paperclip/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { projectService, logActivity } from "../services/index.js";
|
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
|
|
export function projectRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = projectService(db);
|
|
|
|
router.get("/companies/:companyId/projects", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.list(companyId);
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/projects/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const project = await svc.getById(id);
|
|
if (!project) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, project.companyId);
|
|
res.json(project);
|
|
});
|
|
|
|
router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const project = await svc.create(companyId, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.created",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: { name: project.name },
|
|
});
|
|
res.status(201).json(project);
|
|
});
|
|
|
|
router.patch("/projects/:id", validate(updateProjectSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const project = await svc.update(id, req.body);
|
|
if (!project) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.updated",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
details: req.body,
|
|
});
|
|
|
|
res.json(project);
|
|
});
|
|
|
|
router.get("/projects/:id/workspaces", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const workspaces = await svc.listWorkspaces(id);
|
|
res.json(workspaces);
|
|
});
|
|
|
|
router.post("/projects/:id/workspaces", validate(createProjectWorkspaceSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const workspace = await svc.createWorkspace(id, req.body);
|
|
if (!workspace) {
|
|
res.status(422).json({ error: "Invalid project workspace payload" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.workspace_created",
|
|
entityType: "project",
|
|
entityId: id,
|
|
details: {
|
|
workspaceId: workspace.id,
|
|
name: workspace.name,
|
|
cwd: workspace.cwd,
|
|
isPrimary: workspace.isPrimary,
|
|
},
|
|
});
|
|
|
|
res.status(201).json(workspace);
|
|
});
|
|
|
|
router.patch(
|
|
"/projects/:id/workspaces/:workspaceId",
|
|
validate(updateProjectWorkspaceSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const workspaceId = req.params.workspaceId as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId);
|
|
if (!workspaceExists) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
return;
|
|
}
|
|
const workspace = await svc.updateWorkspace(id, workspaceId, req.body);
|
|
if (!workspace) {
|
|
res.status(422).json({ error: "Invalid project workspace payload" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.workspace_updated",
|
|
entityType: "project",
|
|
entityId: id,
|
|
details: {
|
|
workspaceId: workspace.id,
|
|
changedKeys: Object.keys(req.body).sort(),
|
|
},
|
|
});
|
|
|
|
res.json(workspace);
|
|
},
|
|
);
|
|
|
|
router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const workspaceId = req.params.workspaceId as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const workspace = await svc.removeWorkspace(id, workspaceId);
|
|
if (!workspace) {
|
|
res.status(404).json({ error: "Project workspace not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.workspace_deleted",
|
|
entityType: "project",
|
|
entityId: id,
|
|
details: {
|
|
workspaceId: workspace.id,
|
|
name: workspace.name,
|
|
},
|
|
});
|
|
|
|
res.json(workspace);
|
|
});
|
|
|
|
router.delete("/projects/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const project = await svc.remove(id);
|
|
if (!project) {
|
|
res.status(404).json({ error: "Project not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: project.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
action: "project.deleted",
|
|
entityType: "project",
|
|
entityId: project.id,
|
|
});
|
|
|
|
res.json(project);
|
|
});
|
|
|
|
return router;
|
|
}
|