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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
88
server/src/routes/approvals.ts
Normal file
88
server/src/routes/approvals.ts
Normal 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;
|
||||
}
|
||||
30
server/src/routes/authz.ts
Normal file
30
server/src/routes/authz.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
82
server/src/routes/companies.ts
Normal file
82
server/src/routes/companies.ts
Normal 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
123
server/src/routes/costs.ts
Normal 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;
|
||||
}
|
||||
18
server/src/routes/dashboard.ts
Normal file
18
server/src/routes/dashboard.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user