Add server routes for companies, approvals, costs, and dashboard

New routes: companies, approvals, costs, dashboard, authz. New
services: companies, approvals, costs, dashboard, heartbeat,
activity-log. Add auth middleware and structured error handling.
Expand existing agent and issue routes with richer CRUD operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 09:07:27 -06:00
parent 8c830eae70
commit abadd469bc
29 changed files with 2151 additions and 98 deletions

View File

@@ -3,11 +3,14 @@ import { z } from "zod";
import type { Db } from "@paperclip/db";
import { validate } from "../middleware/validate.js";
import { activityService } from "../services/activity.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
const createActivitySchema = z.object({
actorType: z.enum(["agent", "user", "system"]).optional().default("system"),
actorId: z.string().min(1),
action: z.string().min(1),
entityType: z.string().min(1),
entityId: z.string().uuid(),
entityId: z.string().min(1),
agentId: z.string().uuid().optional().nullable(),
details: z.record(z.unknown()).optional().nullable(),
});
@@ -16,8 +19,12 @@ export function activityRoutes(db: Db) {
const router = Router();
const svc = activityService(db);
router.get("/", async (req, res) => {
router.get("/companies/:companyId/activity", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const filters = {
companyId,
agentId: req.query.agentId as string | undefined,
entityType: req.query.entityType as string | undefined,
entityId: req.query.entityId as string | undefined,
@@ -26,8 +33,13 @@ export function activityRoutes(db: Db) {
res.json(result);
});
router.post("/", validate(createActivitySchema), async (req, res) => {
const event = await svc.create(req.body);
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const event = await svc.create({
companyId,
...req.body,
});
res.status(201).json(event);
});

View File

@@ -1,52 +1,258 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createAgentSchema, updateAgentSchema } from "@paperclip/shared";
import {
createAgentKeySchema,
createAgentSchema,
updateAgentSchema,
} from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { agentService } from "../services/agents.js";
import { agentService, heartbeatService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function agentRoutes(db: Db) {
const router = Router();
const svc = agentService(db);
const heartbeat = heartbeatService(db);
router.get("/", async (_req, res) => {
const result = await svc.list();
router.get("/companies/:companyId/agents", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
});
router.get("/:id", async (req, res) => {
router.get("/companies/:companyId/org", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const tree = await svc.orgForCompany(companyId);
res.json(tree);
});
router.get("/agents/:id", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
res.json(agent);
});
router.post("/", validate(createAgentSchema), async (req, res) => {
const agent = await svc.create(req.body);
router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type === "agent") {
assertBoard(req);
}
const agent = await svc.create(companyId, {
...req.body,
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "agent.created",
entityType: "agent",
entityId: agent.id,
details: { name: agent.name, role: agent.role },
});
res.status(201).json(agent);
});
router.patch("/:id", validate(updateAgentSchema), async (req, res) => {
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only modify itself" });
return;
}
const agent = await svc.update(id, req.body);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: agent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "agent.updated",
entityType: "agent",
entityId: agent.id,
details: req.body,
});
res.json(agent);
});
router.delete("/:id", async (req, res) => {
router.post("/agents/:id/pause", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.remove(id);
const agent = await svc.pause(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
await heartbeat.cancelActiveForAgent(id);
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.paused",
entityType: "agent",
entityId: agent.id,
});
res.json(agent);
});
router.post("/agents/:id/resume", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.resume(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.resumed",
entityType: "agent",
entityId: agent.id,
});
res.json(agent);
});
router.post("/agents/:id/terminate", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.terminate(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
await heartbeat.cancelActiveForAgent(id);
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.terminated",
entityType: "agent",
entityId: agent.id,
});
res.json(agent);
});
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const key = await svc.createApiKey(id, req.body.name);
const agent = await svc.getById(id);
if (agent) {
await logActivity(db, {
companyId: agent.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "agent.key_created",
entityType: "agent",
entityId: agent.id,
details: { keyId: key.id, name: key.name },
});
}
res.status(201).json(key);
});
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== id) {
res.status(403).json({ error: "Agent can only invoke itself" });
return;
}
const run = await heartbeat.invoke(id, "manual", {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: agent.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "heartbeat.invoked",
entityType: "heartbeat_run",
entityId: run.id,
details: { agentId: id },
});
res.status(202).json(run);
});
router.get("/companies/:companyId/heartbeat-runs", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const agentId = req.query.agentId as string | undefined;
const runs = await heartbeat.list(companyId, agentId);
res.json(runs);
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;
const run = await heartbeat.cancelRun(runId);
if (run) {
await logActivity(db, {
companyId: run.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: run.id,
details: { agentId: run.agentId },
});
}
res.json(run);
});
return router;
}

View File

@@ -0,0 +1,88 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createApprovalSchema, resolveApprovalSchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { approvalService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function approvalRoutes(db: Db) {
const router = Router();
const svc = approvalService(db);
router.get("/companies/:companyId/approvals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const status = req.query.status as string | undefined;
const result = await svc.list(companyId, status);
res.json(result);
});
router.post("/companies/:companyId/approvals", validate(createApprovalSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const actor = getActorInfo(req);
const approval = await svc.create(companyId, {
...req.body,
requestedByUserId: actor.actorType === "user" ? actor.actorId : null,
requestedByAgentId:
req.body.requestedByAgentId ?? (actor.actorType === "agent" ? actor.actorId : null),
status: "pending",
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
updatedAt: new Date(),
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "approval.created",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
res.status(201).json(approval);
});
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.approved",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
res.json(approval);
});
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
await logActivity(db, {
companyId: approval.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "approval.rejected",
entityType: "approval",
entityId: approval.id,
details: { type: approval.type },
});
res.json(approval);
});
return router;
}

View File

@@ -0,0 +1,30 @@
import type { Request } from "express";
import { forbidden } from "../errors.js";
export function assertBoard(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
}
export function assertCompanyAccess(req: Request, companyId: string) {
if (req.actor.type === "agent" && req.actor.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
}
export function getActorInfo(req: Request) {
if (req.actor.type === "agent") {
return {
actorType: "agent" as const,
actorId: req.actor.agentId ?? "unknown-agent",
agentId: req.actor.agentId ?? null,
};
}
return {
actorType: "user" as const,
actorId: req.actor.userId ?? "board",
agentId: null,
};
}

View File

@@ -0,0 +1,82 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createCompanySchema, updateCompanySchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { companyService, logActivity } from "../services/index.js";
import { assertBoard } from "./authz.js";
export function companyRoutes(db: Db) {
const router = Router();
const svc = companyService(db);
router.get("/", async (_req, res) => {
const result = await svc.list();
res.json(result);
});
router.get("/:companyId", async (req, res) => {
const companyId = req.params.companyId as string;
const company = await svc.getById(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
res.json(company);
});
router.post("/", validate(createCompanySchema), async (req, res) => {
assertBoard(req);
const company = await svc.create(req.body);
await logActivity(db, {
companyId: company.id,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company.created",
entityType: "company",
entityId: company.id,
details: { name: company.name },
});
res.status(201).json(company);
});
router.patch("/:companyId", validate(updateCompanySchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const company = await svc.update(companyId, req.body);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company.updated",
entityType: "company",
entityId: companyId,
details: req.body,
});
res.json(company);
});
router.post("/:companyId/archive", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const company = await svc.archive(companyId);
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company.archived",
entityType: "company",
entityId: companyId,
});
res.json(company);
});
return router;
}

123
server/src/routes/costs.ts Normal file
View File

@@ -0,0 +1,123 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createCostEventSchema, updateBudgetSchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { costService, companyService, agentService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function costRoutes(db: Db) {
const router = Router();
const costs = costService(db);
const companies = companyService(db);
const agents = agentService(db);
router.post("/companies/:companyId/cost-events", validate(createCostEventSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
res.status(403).json({ error: "Agent can only report its own costs" });
return;
}
const event = await costs.createEvent(companyId, {
...req.body,
occurredAt: new Date(req.body.occurredAt),
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "cost.reported",
entityType: "cost_event",
entityId: event.id,
details: { costCents: event.costCents, model: event.model },
});
res.status(201).json(event);
});
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summary = await costs.summary(companyId);
res.json(summary);
});
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await costs.byAgent(companyId);
res.json(rows);
});
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const rows = await costs.byProject(companyId);
res.json(rows);
});
router.patch("/companies/:companyId/budgets", validate(updateBudgetSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const company = await companies.update(companyId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
if (!company) {
res.status(404).json({ error: "Company not found" });
return;
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "company.budget_updated",
entityType: "company",
entityId: companyId,
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
});
res.json(company);
});
router.patch("/agents/:agentId/budgets", validate(updateBudgetSchema), async (req, res) => {
const agentId = req.params.agentId as string;
const agent = await agents.getById(agentId);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
if (req.actor.type === "agent") {
if (req.actor.agentId !== agentId) {
res.status(403).json({ error: "Agent can only change its own budget" });
return;
}
}
const updated = await agents.update(agentId, { budgetMonthlyCents: req.body.budgetMonthlyCents });
if (!updated) {
res.status(404).json({ error: "Agent not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: updated.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "agent.budget_updated",
entityType: "agent",
entityId: updated.id,
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
});
res.json(updated);
});
return router;
}

View File

@@ -0,0 +1,18 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { dashboardService } from "../services/dashboard.js";
import { assertCompanyAccess } from "./authz.js";
export function dashboardRoutes(db: Db) {
const router = Router();
const svc = dashboardService(db);
router.get("/companies/:companyId/dashboard", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summary = await svc.summary(companyId);
res.json(summary);
});
return router;
}

View File

@@ -2,49 +2,103 @@ import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createGoalSchema, updateGoalSchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { goalService } from "../services/goals.js";
import { goalService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function goalRoutes(db: Db) {
const router = Router();
const svc = goalService(db);
router.get("/", async (_req, res) => {
const result = await svc.list();
router.get("/companies/:companyId/goals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
});
router.get("/:id", async (req, res) => {
router.get("/goals/:id", async (req, res) => {
const id = req.params.id as string;
const goal = await svc.getById(id);
if (!goal) {
res.status(404).json({ error: "Goal not found" });
return;
}
assertCompanyAccess(req, goal.companyId);
res.json(goal);
});
router.post("/", validate(createGoalSchema), async (req, res) => {
const goal = await svc.create(req.body);
router.post("/companies/:companyId/goals", validate(createGoalSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const goal = await svc.create(companyId, req.body);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "goal.created",
entityType: "goal",
entityId: goal.id,
details: { title: goal.title },
});
res.status(201).json(goal);
});
router.patch("/:id", validate(updateGoalSchema), async (req, res) => {
router.patch("/goals/:id", validate(updateGoalSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Goal not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const goal = await svc.update(id, req.body);
if (!goal) {
res.status(404).json({ error: "Goal not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: goal.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "goal.updated",
entityType: "goal",
entityId: goal.id,
details: req.body,
});
res.json(goal);
});
router.delete("/:id", async (req, res) => {
router.delete("/goals/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Goal not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const goal = await svc.remove(id);
if (!goal) {
res.status(404).json({ error: "Goal not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: goal.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "goal.deleted",
entityType: "goal",
entityId: goal.id,
});
res.json(goal);
});

View File

@@ -1,6 +1,10 @@
export { healthRoutes } from "./health.js";
export { companyRoutes } from "./companies.js";
export { agentRoutes } from "./agents.js";
export { projectRoutes } from "./projects.js";
export { issueRoutes } from "./issues.js";
export { goalRoutes } from "./goals.js";
export { approvalRoutes } from "./approvals.js";
export { costRoutes } from "./costs.js";
export { activityRoutes } from "./activity.js";
export { dashboardRoutes } from "./dashboard.js";

View File

@@ -1,52 +1,225 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createIssueSchema, updateIssueSchema } from "@paperclip/shared";
import {
addIssueCommentSchema,
checkoutIssueSchema,
createIssueSchema,
updateIssueSchema,
} from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { issueService } from "../services/issues.js";
import { issueService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
export function issueRoutes(db: Db) {
const router = Router();
const svc = issueService(db);
router.get("/", async (_req, res) => {
const result = await svc.list();
router.get("/companies/:companyId/issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId, {
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
projectId: req.query.projectId as string | undefined,
});
res.json(result);
});
router.get("/:id", async (req, res) => {
router.get("/issues/:id", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
res.json(issue);
});
router.post("/", validate(createIssueSchema), async (req, res) => {
const issue = await svc.create(req.body);
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const actor = getActorInfo(req);
const issue = await svc.create(companyId, {
...req.body,
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.created",
entityType: "issue",
entityId: issue.id,
details: { title: issue.title },
});
res.status(201).json(issue);
});
router.patch("/:id", validate(updateIssueSchema), async (req, res) => {
router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const issue = await svc.update(id, req.body);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: req.body,
});
res.json(issue);
});
router.delete("/:id", async (req, res) => {
router.delete("/issues/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const issue = await svc.remove(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.deleted",
entityType: "issue",
entityId: issue.id,
});
res.json(issue);
});
router.post("/issues/:id/checkout", validate(checkoutIssueSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
res.status(403).json({ error: "Agent can only checkout as itself" });
return;
}
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.checked_out",
entityType: "issue",
entityId: issue.id,
details: { agentId: req.body.agentId },
});
res.json(updated);
});
router.post("/issues/:id/release", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const released = await svc.release(id, req.actor.type === "agent" ? req.actor.agentId : undefined);
if (!released) {
res.status(404).json({ error: "Issue not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: released.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.released",
entityType: "issue",
entityId: released.id,
});
res.json(released);
});
router.get("/issues/:id/comments", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const comments = await svc.listComments(id);
res.json(comments);
});
router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const actor = getActorInfo(req);
const comment = await svc.addComment(id, req.body.body, {
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
});
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "issue.comment_added",
entityType: "issue",
entityId: issue.id,
details: { commentId: comment.id },
});
res.status(201).json(comment);
});
return router;
}

View File

@@ -2,49 +2,103 @@ import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createProjectSchema, updateProjectSchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { projectService } from "../services/projects.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("/", async (_req, res) => {
const result = await svc.list();
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("/:id", async (req, res) => {
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("/", validate(createProjectSchema), async (req, res) => {
const project = await svc.create(req.body);
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("/:id", validate(updateProjectSchema), async (req, res) => {
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.delete("/:id", async (req, res) => {
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);
});