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,7 +3,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "pnpm run --parallel dev:server dev:ui", "dev": "pnpm --parallel --filter @paperclip/server --filter @paperclip/ui dev",
"dev:server": "pnpm --filter @paperclip/server dev", "dev:server": "pnpm --filter @paperclip/server dev",
"dev:ui": "pnpm --filter @paperclip/ui dev", "dev:ui": "pnpm --filter @paperclip/ui dev",
"build": "pnpm -r build", "build": "pnpm -r build",

View File

@@ -3,27 +3,37 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { httpLogger, errorHandler } from "./middleware/index.js"; import { httpLogger, errorHandler } from "./middleware/index.js";
import { actorMiddleware } from "./middleware/auth.js";
import { healthRoutes } from "./routes/health.js"; import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { agentRoutes } from "./routes/agents.js"; import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js"; import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js"; import { issueRoutes } from "./routes/issues.js";
import { goalRoutes } from "./routes/goals.js"; import { goalRoutes } from "./routes/goals.js";
import { approvalRoutes } from "./routes/approvals.js";
import { costRoutes } from "./routes/costs.js";
import { activityRoutes } from "./routes/activity.js"; import { activityRoutes } from "./routes/activity.js";
import { dashboardRoutes } from "./routes/dashboard.js";
export function createApp(db: Db, opts: { serveUi: boolean }) { export function createApp(db: Db, opts: { serveUi: boolean }) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(httpLogger); app.use(httpLogger);
app.use(actorMiddleware(db));
// Mount API routes // Mount API routes
const api = Router(); const api = Router();
api.use("/health", healthRoutes()); api.use("/health", healthRoutes());
api.use("/agents", agentRoutes(db)); api.use("/companies", companyRoutes(db));
api.use("/projects", projectRoutes(db)); api.use(agentRoutes(db));
api.use("/issues", issueRoutes(db)); api.use(projectRoutes(db));
api.use("/goals", goalRoutes(db)); api.use(issueRoutes(db));
api.use("/activity", activityRoutes(db)); api.use(goalRoutes(db));
api.use(approvalRoutes(db));
api.use(costRoutes(db));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
app.use("/api", api); app.use("/api", api);
// SPA fallback for serving the UI build // SPA fallback for serving the UI build

34
server/src/errors.ts Normal file
View File

@@ -0,0 +1,34 @@
export class HttpError extends Error {
status: number;
details?: unknown;
constructor(status: number, message: string, details?: unknown) {
super(message);
this.status = status;
this.details = details;
}
}
export function badRequest(message: string, details?: unknown) {
return new HttpError(400, message, details);
}
export function unauthorized(message = "Unauthorized") {
return new HttpError(401, message);
}
export function forbidden(message = "Forbidden") {
return new HttpError(403, message);
}
export function notFound(message = "Not found") {
return new HttpError(404, message);
}
export function conflict(message: string, details?: unknown) {
return new HttpError(409, message, details);
}
export function unprocessable(message: string, details?: unknown) {
return new HttpError(422, message, details);
}

View File

@@ -0,0 +1,57 @@
import { createHash } from "node:crypto";
import type { RequestHandler } from "express";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agentApiKeys } from "@paperclip/db";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function actorMiddleware(db: Db): RequestHandler {
return async (req, _res, next) => {
req.actor = { type: "board", userId: "board" };
const authHeader = req.header("authorization");
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
next();
return;
}
const token = authHeader.slice("bearer ".length).trim();
if (!token) {
next();
return;
}
const tokenHash = hashToken(token);
const key = await db
.select()
.from(agentApiKeys)
.where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))
.then((rows) => rows[0] ?? null);
if (!key) {
next();
return;
}
await db
.update(agentApiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(agentApiKeys.id, key.id));
req.actor = {
type: "agent",
agentId: key.agentId,
companyId: key.companyId,
keyId: key.id,
};
next();
};
}
export function requireBoard(req: Express.Request) {
return req.actor.type === "board";
}

View File

@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { HttpError } from "../errors.js";
export function errorHandler( export function errorHandler(
err: unknown, err: unknown,
@@ -8,6 +9,14 @@ export function errorHandler(
res: Response, res: Response,
_next: NextFunction, _next: NextFunction,
) { ) {
if (err instanceof HttpError) {
res.status(err.status).json({
error: err.message,
...(err.details ? { details: err.details } : {}),
});
return;
}
if (err instanceof ZodError) { if (err instanceof ZodError) {
res.status(400).json({ error: "Validation error", details: err.errors }); res.status(400).json({ error: "Validation error", details: err.errors });
return; return;

View File

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

View File

@@ -1,52 +1,258 @@
import { Router } from "express"; import { Router } from "express";
import type { Db } from "@paperclip/db"; 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 { 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) { export function agentRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = agentService(db); const svc = agentService(db);
const heartbeat = heartbeatService(db);
router.get("/", async (_req, res) => { router.get("/companies/:companyId/agents", async (req, res) => {
const result = await svc.list(); const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result); 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 id = req.params.id as string;
const agent = await svc.getById(id); const agent = await svc.getById(id);
if (!agent) { if (!agent) {
res.status(404).json({ error: "Agent not found" }); res.status(404).json({ error: "Agent not found" });
return; return;
} }
assertCompanyAccess(req, agent.companyId);
res.json(agent); res.json(agent);
}); });
router.post("/", validate(createAgentSchema), async (req, res) => { router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => {
const agent = await svc.create(req.body); 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); 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 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); const agent = await svc.update(id, req.body);
if (!agent) { if (!agent) {
res.status(404).json({ error: "Agent not found" }); res.status(404).json({ error: "Agent not found" });
return; 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); 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 id = req.params.id as string;
const agent = await svc.remove(id); const agent = await svc.pause(id);
if (!agent) { if (!agent) {
res.status(404).json({ error: "Agent not found" }); res.status(404).json({ error: "Agent not found" });
return; 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); 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; 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 type { Db } from "@paperclip/db";
import { createGoalSchema, updateGoalSchema } from "@paperclip/shared"; import { createGoalSchema, updateGoalSchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js"; 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) { export function goalRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = goalService(db); const svc = goalService(db);
router.get("/", async (_req, res) => { router.get("/companies/:companyId/goals", async (req, res) => {
const result = await svc.list(); const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result); res.json(result);
}); });
router.get("/:id", async (req, res) => { router.get("/goals/:id", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const goal = await svc.getById(id); const goal = await svc.getById(id);
if (!goal) { if (!goal) {
res.status(404).json({ error: "Goal not found" }); res.status(404).json({ error: "Goal not found" });
return; return;
} }
assertCompanyAccess(req, goal.companyId);
res.json(goal); res.json(goal);
}); });
router.post("/", validate(createGoalSchema), async (req, res) => { router.post("/companies/:companyId/goals", validate(createGoalSchema), async (req, res) => {
const goal = await svc.create(req.body); 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); 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 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); const goal = await svc.update(id, req.body);
if (!goal) { if (!goal) {
res.status(404).json({ error: "Goal not found" }); res.status(404).json({ error: "Goal not found" });
return; 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); res.json(goal);
}); });
router.delete("/:id", async (req, res) => { router.delete("/goals/:id", async (req, res) => {
const id = req.params.id as string; 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); const goal = await svc.remove(id);
if (!goal) { if (!goal) {
res.status(404).json({ error: "Goal not found" }); res.status(404).json({ error: "Goal not found" });
return; 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); res.json(goal);
}); });

View File

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

View File

@@ -1,52 +1,225 @@
import { Router } from "express"; import { Router } from "express";
import type { Db } from "@paperclip/db"; 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 { 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) { export function issueRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = issueService(db); const svc = issueService(db);
router.get("/", async (_req, res) => { router.get("/companies/:companyId/issues", async (req, res) => {
const result = await svc.list(); 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); res.json(result);
}); });
router.get("/:id", async (req, res) => { router.get("/issues/:id", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const issue = await svc.getById(id); const issue = await svc.getById(id);
if (!issue) { if (!issue) {
res.status(404).json({ error: "Issue not found" }); res.status(404).json({ error: "Issue not found" });
return; return;
} }
assertCompanyAccess(req, issue.companyId);
res.json(issue); res.json(issue);
}); });
router.post("/", validate(createIssueSchema), async (req, res) => { router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
const issue = await svc.create(req.body); 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); 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 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); const issue = await svc.update(id, req.body);
if (!issue) { if (!issue) {
res.status(404).json({ error: "Issue not found" }); res.status(404).json({ error: "Issue not found" });
return; 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); res.json(issue);
}); });
router.delete("/:id", async (req, res) => { router.delete("/issues/:id", async (req, res) => {
const id = req.params.id as string; 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); const issue = await svc.remove(id);
if (!issue) { if (!issue) {
res.status(404).json({ error: "Issue not found" }); res.status(404).json({ error: "Issue not found" });
return; 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); 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; return router;
} }

View File

@@ -2,49 +2,103 @@ import { Router } from "express";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { createProjectSchema, updateProjectSchema } from "@paperclip/shared"; import { createProjectSchema, updateProjectSchema } from "@paperclip/shared";
import { validate } from "../middleware/validate.js"; 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) { export function projectRoutes(db: Db) {
const router = Router(); const router = Router();
const svc = projectService(db); const svc = projectService(db);
router.get("/", async (_req, res) => { router.get("/companies/:companyId/projects", async (req, res) => {
const result = await svc.list(); const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result); res.json(result);
}); });
router.get("/:id", async (req, res) => { router.get("/projects/:id", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const project = await svc.getById(id); const project = await svc.getById(id);
if (!project) { if (!project) {
res.status(404).json({ error: "Project not found" }); res.status(404).json({ error: "Project not found" });
return; return;
} }
assertCompanyAccess(req, project.companyId);
res.json(project); res.json(project);
}); });
router.post("/", validate(createProjectSchema), async (req, res) => { router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => {
const project = await svc.create(req.body); 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); 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 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); const project = await svc.update(id, req.body);
if (!project) { if (!project) {
res.status(404).json({ error: "Project not found" }); res.status(404).json({ error: "Project not found" });
return; 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); res.json(project);
}); });
router.delete("/:id", async (req, res) => { router.delete("/projects/:id", async (req, res) => {
const id = req.params.id as string; 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); const project = await svc.remove(id);
if (!project) { if (!project) {
res.status(404).json({ error: "Project not found" }); res.status(404).json({ error: "Project not found" });
return; 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); res.json(project);
}); });

View File

@@ -0,0 +1,26 @@
import type { Db } from "@paperclip/db";
import { activityLog } from "@paperclip/db";
export interface LogActivityInput {
companyId: string;
actorType: "agent" | "user" | "system";
actorId: string;
action: string;
entityType: string;
entityId: string;
agentId?: string | null;
details?: Record<string, unknown> | null;
}
export async function logActivity(db: Db, input: LogActivityInput) {
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,
actorId: input.actorId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
agentId: input.agentId ?? null,
details: input.details ?? null,
});
}

View File

@@ -1,8 +1,9 @@
import { eq, and, desc } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { activityLog } from "@paperclip/db"; import { activityLog } from "@paperclip/db";
export interface ActivityFilters { export interface ActivityFilters {
companyId: string;
agentId?: string; agentId?: string;
entityType?: string; entityType?: string;
entityId?: string; entityId?: string;
@@ -10,26 +11,20 @@ export interface ActivityFilters {
export function activityService(db: Db) { export function activityService(db: Db) {
return { return {
list: (filters?: ActivityFilters) => { list: (filters: ActivityFilters) => {
const conditions = []; const conditions = [eq(activityLog.companyId, filters.companyId)];
if (filters?.agentId) { if (filters.agentId) {
conditions.push(eq(activityLog.agentId, filters.agentId)); conditions.push(eq(activityLog.agentId, filters.agentId));
} }
if (filters?.entityType) { if (filters.entityType) {
conditions.push(eq(activityLog.entityType, filters.entityType)); conditions.push(eq(activityLog.entityType, filters.entityType));
} }
if (filters?.entityId) { if (filters.entityId) {
conditions.push(eq(activityLog.entityId, filters.entityId)); conditions.push(eq(activityLog.entityId, filters.entityId));
} }
const query = db.select().from(activityLog); return db.select().from(activityLog).where(and(...conditions)).orderBy(desc(activityLog.createdAt));
if (conditions.length > 0) {
return query.where(and(...conditions)).orderBy(desc(activityLog.createdAt));
}
return query.orderBy(desc(activityLog.createdAt));
}, },
create: (data: typeof activityLog.$inferInsert) => create: (data: typeof activityLog.$inferInsert) =>

View File

@@ -1,38 +1,183 @@
import { eq } from "drizzle-orm"; import { createHash, randomBytes } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { agents } from "@paperclip/db"; import { agents, agentApiKeys, heartbeatRuns } from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createToken() {
return `pcp_${randomBytes(24).toString("hex")}`;
}
export function agentService(db: Db) { export function agentService(db: Db) {
async function getById(id: string) {
return db
.select()
.from(agents)
.where(eq(agents.id, id))
.then((rows) => rows[0] ?? null);
}
async function ensureManager(companyId: string, managerId: string) {
const manager = await getById(managerId);
if (!manager) throw notFound("Manager not found");
if (manager.companyId !== companyId) {
throw unprocessable("Manager must belong to same company");
}
return manager;
}
async function assertNoCycle(agentId: string, reportsTo: string | null | undefined) {
if (!reportsTo) return;
if (reportsTo === agentId) throw unprocessable("Agent cannot report to itself");
let cursor: string | null = reportsTo;
while (cursor) {
if (cursor === agentId) throw unprocessable("Reporting relationship would create cycle");
const next = await getById(cursor);
cursor = next?.reportsTo ?? null;
}
}
return { return {
list: () => db.select().from(agents), list: (companyId: string) =>
db.select().from(agents).where(eq(agents.companyId, companyId)),
getById: (id: string) => getById,
db
.select()
.from(agents)
.where(eq(agents.id, id))
.then((rows) => rows[0] ?? null),
create: (data: typeof agents.$inferInsert) => create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {
db if (data.reportsTo) {
await ensureManager(companyId, data.reportsTo);
}
const created = await db
.insert(agents) .insert(agents)
.values(data) .values({ ...data, companyId })
.returning() .returning()
.then((rows) => rows[0]), .then((rows) => rows[0]);
update: (id: string, data: Partial<typeof agents.$inferInsert>) => return created;
db },
update: async (id: string, data: Partial<typeof agents.$inferInsert>) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated" && data.status && data.status !== "terminated") {
throw conflict("Terminated agents cannot be resumed");
}
if (data.reportsTo !== undefined) {
if (data.reportsTo) {
await ensureManager(existing.companyId, data.reportsTo);
}
await assertNoCycle(id, data.reportsTo);
}
return db
.update(agents) .update(agents)
.set({ ...data, updatedAt: new Date() }) .set({ ...data, updatedAt: new Date() })
.where(eq(agents.id, id)) .where(eq(agents.id, id))
.returning() .returning()
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null);
},
remove: (id: string) => pause: async (id: string) => {
db const existing = await getById(id);
.delete(agents) if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
return db
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(agents.id, id)) .where(eq(agents.id, id))
.returning() .returning()
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null);
},
resume: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot resume terminated agent");
return db
.update(agents)
.set({ status: "idle", updatedAt: new Date() })
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
},
terminate: async (id: string) => {
const existing = await getById(id);
if (!existing) return null;
await db
.update(agents)
.set({ status: "terminated", updatedAt: new Date() })
.where(eq(agents.id, id));
await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.agentId, id));
return getById(id);
},
createApiKey: async (id: string, name: string) => {
const existing = await getById(id);
if (!existing) throw notFound("Agent not found");
const token = createToken();
const keyHash = hashToken(token);
const created = await db
.insert(agentApiKeys)
.values({
agentId: id,
companyId: existing.companyId,
name,
keyHash,
})
.returning()
.then((rows) => rows[0]);
return {
id: created.id,
name: created.name,
token,
createdAt: created.createdAt,
};
},
orgForCompany: async (companyId: string) => {
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
const byManager = new Map<string | null, typeof rows>();
for (const row of rows) {
const key = row.reportsTo ?? null;
const group = byManager.get(key) ?? [];
group.push(row);
byManager.set(key, group);
}
const build = (managerId: string | null): Array<Record<string, unknown>> => {
const members = byManager.get(managerId) ?? [];
return members.map((member) => ({
...member,
reports: build(member.id),
}));
};
return build(null);
},
runningForAgent: (agentId: string) =>
db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))),
}; };
} }

View File

@@ -0,0 +1,113 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { approvals } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
import { agentService } from "./agents.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
return {
list: (companyId: string, status?: string) => {
const conditions = [eq(approvals.companyId, companyId)];
if (status) conditions.push(eq(approvals.status, status));
return db.select().from(approvals).where(and(...conditions));
},
getById: (id: string) =>
db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null),
create: (companyId: string, data: Omit<typeof approvals.$inferInsert, "companyId">) =>
db
.insert(approvals)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]),
approve: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Approval not found");
if (existing.status !== "pending") {
throw unprocessable("Only pending approvals can be approved");
}
const now = new Date();
const updated = await db
.update(approvals)
.set({
status: "approved",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
if (updated.type === "hire_agent") {
const payload = updated.payload as Record<string, unknown>;
await agentsSvc.create(updated.companyId, {
name: String(payload.name ?? "New Agent"),
role: String(payload.role ?? "general"),
title: typeof payload.title === "string" ? payload.title : null,
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
capabilities: typeof payload.capabilities === "string" ? payload.capabilities : null,
adapterType: String(payload.adapterType ?? "process"),
adapterConfig:
typeof payload.adapterConfig === "object" && payload.adapterConfig !== null
? (payload.adapterConfig as Record<string, unknown>)
: {},
contextMode: String(payload.contextMode ?? "thin"),
budgetMonthlyCents:
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0,
metadata:
typeof payload.metadata === "object" && payload.metadata !== null
? (payload.metadata as Record<string, unknown>)
: null,
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
}
return updated;
},
reject: async (id: string, decidedByUserId: string, decisionNote?: string | null) => {
const existing = await db
.select()
.from(approvals)
.where(eq(approvals.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) throw notFound("Approval not found");
if (existing.status !== "pending") {
throw unprocessable("Only pending approvals can be rejected");
}
const now = new Date();
return db
.update(approvals)
.set({
status: "rejected",
decidedByUserId,
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(eq(approvals.id, id))
.returning()
.then((rows) => rows[0]);
},
};
}

View File

@@ -0,0 +1,39 @@
import { eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { companies } from "@paperclip/db";
export function companyService(db: Db) {
return {
list: () => db.select().from(companies),
getById: (id: string) =>
db
.select()
.from(companies)
.where(eq(companies.id, id))
.then((rows) => rows[0] ?? null),
create: (data: typeof companies.$inferInsert) =>
db
.insert(companies)
.values(data)
.returning()
.then((rows) => rows[0]),
update: (id: string, data: Partial<typeof companies.$inferInsert>) =>
db
.update(companies)
.set({ ...data, updatedAt: new Date() })
.where(eq(companies.id, id))
.returning()
.then((rows) => rows[0] ?? null),
archive: (id: string) =>
db
.update(companies)
.set({ status: "archived", updatedAt: new Date() })
.where(eq(companies.id, id))
.returning()
.then((rows) => rows[0] ?? null),
};
}

View File

@@ -0,0 +1,112 @@
import { and, desc, eq, isNotNull, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, companies, costEvents } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
export function costService(db: Db) {
return {
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
const agent = await db
.select()
.from(agents)
.where(eq(agents.id, data.agentId))
.then((rows) => rows[0] ?? null);
if (!agent) throw notFound("Agent not found");
if (agent.companyId !== companyId) {
throw unprocessable("Agent does not belong to company");
}
const event = await db
.insert(costEvents)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]);
await db
.update(agents)
.set({
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${event.costCents}`,
updatedAt: new Date(),
})
.where(eq(agents.id, event.agentId));
await db
.update(companies)
.set({
spentMonthlyCents: sql`${companies.spentMonthlyCents} + ${event.costCents}`,
updatedAt: new Date(),
})
.where(eq(companies.id, companyId));
const updatedAgent = await db
.select()
.from(agents)
.where(eq(agents.id, event.agentId))
.then((rows) => rows[0] ?? null);
if (
updatedAgent &&
updatedAgent.budgetMonthlyCents > 0 &&
updatedAgent.spentMonthlyCents >= updatedAgent.budgetMonthlyCents &&
updatedAgent.status !== "paused" &&
updatedAgent.status !== "terminated"
) {
await db
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(agents.id, updatedAgent.id));
}
return event;
},
summary: async (companyId: string) => {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
const utilization =
company.budgetMonthlyCents > 0
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
: 0;
return {
companyId,
monthSpendCents: company.spentMonthlyCents,
monthBudgetCents: company.budgetMonthlyCents,
monthUtilizationPercent: Number(utilization.toFixed(2)),
};
},
byAgent: async (companyId: string) =>
db
.select({
agentId: costEvents.agentId,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)`,
})
.from(costEvents)
.where(eq(costEvents.companyId, companyId))
.groupBy(costEvents.agentId)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
byProject: async (companyId: string) =>
db
.select({
projectId: costEvents.projectId,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)`,
})
.from(costEvents)
.where(and(eq(costEvents.companyId, companyId), isNotNull(costEvents.projectId)))
.groupBy(costEvents.projectId)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)`)),
};
}

View File

@@ -0,0 +1,96 @@
import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, approvals, companies, issues } from "@paperclip/db";
import { notFound } from "../errors.js";
export function dashboardService(db: Db) {
return {
summary: async (companyId: string) => {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
const agentRows = await db
.select({ status: agents.status, count: sql<number>`count(*)` })
.from(agents)
.where(eq(agents.companyId, companyId))
.groupBy(agents.status);
const taskRows = await db
.select({ status: issues.status, count: sql<number>`count(*)` })
.from(issues)
.where(eq(issues.companyId, companyId))
.groupBy(issues.status);
const pendingApprovals = await db
.select({ count: sql<number>`count(*)` })
.from(approvals)
.where(and(eq(approvals.companyId, companyId), eq(approvals.status, "pending")))
.then((rows) => Number(rows[0]?.count ?? 0));
const staleCutoff = new Date(Date.now() - 60 * 60 * 1000);
const staleTasks = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${staleCutoff.toISOString()}`,
),
)
.then((rows) => Number(rows[0]?.count ?? 0));
const agentCounts: Record<string, number> = {
active: 0,
running: 0,
paused: 0,
error: 0,
};
for (const row of agentRows) {
agentCounts[row.status] = Number(row.count);
}
const taskCounts: Record<string, number> = {
open: 0,
inProgress: 0,
blocked: 0,
done: 0,
};
for (const row of taskRows) {
const count = Number(row.count);
if (row.status === "in_progress") taskCounts.inProgress += count;
if (row.status === "blocked") taskCounts.blocked += count;
if (row.status === "done") taskCounts.done += count;
if (row.status !== "done" && row.status !== "cancelled") taskCounts.open += count;
}
const utilization =
company.budgetMonthlyCents > 0
? (company.spentMonthlyCents / company.budgetMonthlyCents) * 100
: 0;
return {
companyId,
agents: {
active: agentCounts.active,
running: agentCounts.running,
paused: agentCounts.paused,
error: agentCounts.error,
},
tasks: taskCounts,
costs: {
monthSpendCents: company.spentMonthlyCents,
monthBudgetCents: company.budgetMonthlyCents,
monthUtilizationPercent: Number(utilization.toFixed(2)),
},
pendingApprovals,
staleTasks,
};
},
};
}

View File

@@ -4,7 +4,7 @@ import { goals } from "@paperclip/db";
export function goalService(db: Db) { export function goalService(db: Db) {
return { return {
list: () => db.select().from(goals), list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)),
getById: (id: string) => getById: (id: string) =>
db db
@@ -13,10 +13,10 @@ export function goalService(db: Db) {
.where(eq(goals.id, id)) .where(eq(goals.id, id))
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
create: (data: typeof goals.$inferInsert) => create: (companyId: string, data: Omit<typeof goals.$inferInsert, "companyId">) =>
db db
.insert(goals) .insert(goals)
.values(data) .values({ ...data, companyId })
.returning() .returning()
.then((rows) => rows[0]), .then((rows) => rows[0]),

View File

@@ -0,0 +1,354 @@
import { spawn, type ChildProcess } from "node:child_process";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { agents, heartbeatRuns } from "@paperclip/db";
import { conflict, notFound } from "../errors.js";
import { logger } from "../middleware/logger.js";
interface RunningProcess {
child: ChildProcess;
graceSec: number;
}
const runningProcesses = new Map<string, RunningProcess>();
function parseObject(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function asNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
export function heartbeatService(db: Db) {
async function getAgent(agentId: string) {
return db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
}
async function setRunStatus(
runId: string,
status: string,
patch?: Partial<typeof heartbeatRuns.$inferInsert>,
) {
return db
.update(heartbeatRuns)
.set({ status, ...patch, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, runId))
.returning()
.then((rows) => rows[0] ?? null);
}
async function finalizeAgentStatus(agentId: string, ok: boolean) {
const existing = await getAgent(agentId);
if (!existing) return;
if (existing.status === "paused" || existing.status === "terminated") {
return;
}
await db
.update(agents)
.set({
status: ok ? "idle" : "error",
lastHeartbeatAt: new Date(),
updatedAt: new Date(),
})
.where(eq(agents.id, agentId));
}
async function executeHttpRun(runId: string, agentId: string, config: Record<string, unknown>, context: Record<string, unknown>) {
const url = asString(config.url, "");
if (!url) throw new Error("HTTP adapter missing url");
const method = asString(config.method, "POST");
const timeoutMs = asNumber(config.timeoutMs, 15000);
const headers = parseObject(config.headers) as Record<string, string>;
const payloadTemplate = parseObject(config.payloadTemplate);
const body = { ...payloadTemplate, agentId, runId, context };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method,
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`HTTP invoke failed with status ${res.status}`);
}
} finally {
clearTimeout(timer);
}
}
async function executeProcessRun(
runId: string,
_agentId: string,
config: Record<string, unknown>,
) {
const command = asString(config.command, "");
if (!command) throw new Error("Process adapter missing command");
const args = asStringArray(config.args);
const cwd = typeof config.cwd === "string" ? config.cwd : process.cwd();
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v;
}
const timeoutSec = asNumber(config.timeoutSec, 900);
const graceSec = asNumber(config.graceSec, 15);
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...env },
});
runningProcesses.set(runId, { child, graceSec });
const timeout = setTimeout(async () => {
child.kill("SIGTERM");
await setRunStatus(runId, "timed_out", {
error: `Timed out after ${timeoutSec}s`,
finishedAt: new Date(),
});
runningProcesses.delete(runId);
resolve();
}, timeoutSec * 1000);
child.stdout?.on("data", (chunk) => {
logger.info({ runId, output: String(chunk) }, "agent process stdout");
});
child.stderr?.on("data", (chunk) => {
logger.warn({ runId, output: String(chunk) }, "agent process stderr");
});
child.on("error", (err) => {
clearTimeout(timeout);
runningProcesses.delete(runId);
reject(err);
});
child.on("exit", (code, signal) => {
clearTimeout(timeout);
runningProcesses.delete(runId);
if (signal) {
resolve();
return;
}
if (code === 0) {
resolve();
return;
}
reject(new Error(`Process exited with code ${code ?? -1}`));
});
});
}
async function executeRun(runId: string) {
const run = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) {
return;
}
const agent = await getAgent(run.agentId);
if (!agent) {
await setRunStatus(runId, "failed", {
error: "Agent not found",
finishedAt: new Date(),
});
return;
}
await setRunStatus(run.id, "running", { startedAt: new Date() });
await db
.update(agents)
.set({ status: "running", updatedAt: new Date() })
.where(eq(agents.id, agent.id));
try {
const config = parseObject(agent.adapterConfig);
const context = (run.contextSnapshot ?? {}) as Record<string, unknown>;
if (agent.adapterType === "http") {
await executeHttpRun(run.id, agent.id, config, context);
} else {
await executeProcessRun(run.id, agent.id, config);
}
const latestRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, run.id))
.then((rows) => rows[0] ?? null);
if (latestRun?.status === "timed_out" || latestRun?.status === "cancelled") {
await finalizeAgentStatus(agent.id, false);
return;
}
await setRunStatus(run.id, "succeeded", { finishedAt: new Date(), error: null });
await finalizeAgentStatus(agent.id, true);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown adapter failure";
await setRunStatus(run.id, "failed", {
error: message,
finishedAt: new Date(),
});
await finalizeAgentStatus(agent.id, false);
}
}
return {
list: (companyId: string, agentId?: string) => {
if (!agentId) {
return db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
}
return db
.select()
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
},
invoke: async (
agentId: string,
invocationSource: "scheduler" | "manual" | "callback" = "manual",
contextSnapshot: Record<string, unknown> = {},
) => {
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
if (agent.status === "paused" || agent.status === "terminated") {
throw conflict("Agent is not invokable in its current state", { status: agent.status });
}
const activeRun = await db
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, agentId),
inArray(heartbeatRuns.status, ["queued", "running"]),
),
)
.then((rows) => rows[0] ?? null);
if (activeRun) {
throw conflict("Agent already has an active heartbeat run", { runId: activeRun.id });
}
const run = await db
.insert(heartbeatRuns)
.values({
companyId: agent.companyId,
agentId,
invocationSource,
status: "queued",
contextSnapshot,
})
.returning()
.then((rows) => rows[0]);
void executeRun(run.id).catch((err) => {
logger.error({ err, runId: run.id }, "heartbeat execution failed");
});
return run;
},
cancelRun: async (runId: string) => {
const run = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) throw notFound("Heartbeat run not found");
if (run.status !== "running" && run.status !== "queued") return run;
const running = runningProcesses.get(run.id);
if (running) {
running.child.kill("SIGTERM");
const graceMs = Math.max(1, running.graceSec) * 1000;
setTimeout(() => {
if (!running.child.killed) {
running.child.kill("SIGKILL");
}
}, graceMs);
}
const cancelled = await setRunStatus(run.id, "cancelled", {
finishedAt: new Date(),
error: "Cancelled by control plane",
});
runningProcesses.delete(run.id);
return cancelled;
},
cancelActiveForAgent: async (agentId: string) => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, agentId),
inArray(heartbeatRuns.status, ["queued", "running"]),
),
);
for (const run of runs) {
await db
.update(heartbeatRuns)
.set({
status: "cancelled",
finishedAt: new Date(),
error: "Cancelled due to agent pause",
updatedAt: new Date(),
})
.where(eq(heartbeatRuns.id, run.id));
const running = runningProcesses.get(run.id);
if (running) {
running.child.kill("SIGTERM");
runningProcesses.delete(run.id);
}
}
return runs.length;
},
};
}

View File

@@ -1,5 +1,11 @@
export { companyService } from "./companies.js";
export { agentService } from "./agents.js"; export { agentService } from "./agents.js";
export { projectService } from "./projects.js"; export { projectService } from "./projects.js";
export { issueService } from "./issues.js"; export { issueService, type IssueFilters } from "./issues.js";
export { goalService } from "./goals.js"; export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js"; export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
export { costService } from "./costs.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";

View File

@@ -1,10 +1,62 @@
import { eq } from "drizzle-orm"; import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db"; import type { Db } from "@paperclip/db";
import { issues } from "@paperclip/db"; import { issues, issueComments } from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
const ISSUE_TRANSITIONS: Record<string, string[]> = {
backlog: ["todo", "cancelled"],
todo: ["in_progress", "blocked", "cancelled"],
in_progress: ["in_review", "blocked", "done", "cancelled"],
in_review: ["in_progress", "done", "cancelled"],
blocked: ["todo", "in_progress", "cancelled"],
done: [],
cancelled: [],
};
function assertTransition(from: string, to: string) {
if (from === to) return;
const allowed = ISSUE_TRANSITIONS[from] ?? [];
if (!allowed.includes(to)) {
throw conflict(`Invalid issue status transition: ${from} -> ${to}`);
}
}
function applyStatusSideEffects(
status: string | undefined,
patch: Partial<typeof issues.$inferInsert>,
): Partial<typeof issues.$inferInsert> {
if (!status) return patch;
if (status === "in_progress" && !patch.startedAt) {
patch.startedAt = new Date();
}
if (status === "done") {
patch.completedAt = new Date();
}
if (status === "cancelled") {
patch.cancelledAt = new Date();
}
return patch;
}
export interface IssueFilters {
status?: string;
assigneeAgentId?: string;
projectId?: string;
}
export function issueService(db: Db) { export function issueService(db: Db) {
return { return {
list: () => db.select().from(issues), list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
if (filters?.status) conditions.push(eq(issues.status, filters.status));
if (filters?.assigneeAgentId) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
return db.select().from(issues).where(and(...conditions)).orderBy(desc(issues.updatedAt));
},
getById: (id: string) => getById: (id: string) =>
db db
@@ -13,20 +65,55 @@ export function issueService(db: Db) {
.where(eq(issues.id, id)) .where(eq(issues.id, id))
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
create: (data: typeof issues.$inferInsert) => create: (companyId: string, data: Omit<typeof issues.$inferInsert, "companyId">) => {
db const values = { ...data, companyId } as typeof issues.$inferInsert;
.insert(issues) if (values.status === "in_progress" && !values.startedAt) {
.values(data) values.startedAt = new Date();
.returning() }
.then((rows) => rows[0]), if (values.status === "done") {
values.completedAt = new Date();
}
if (values.status === "cancelled") {
values.cancelledAt = new Date();
}
update: (id: string, data: Partial<typeof issues.$inferInsert>) => return db
db .insert(issues)
.values(values)
.returning()
.then((rows) => rows[0]);
},
update: async (id: string, data: Partial<typeof issues.$inferInsert>) => {
const existing = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (data.status) {
assertTransition(existing.status, data.status);
}
const patch: Partial<typeof issues.$inferInsert> = {
...data,
updatedAt: new Date(),
};
if (patch.status === "in_progress" && !patch.assigneeAgentId && !existing.assigneeAgentId) {
throw unprocessable("in_progress issues require an assignee");
}
applyStatusSideEffects(data.status, patch);
return db
.update(issues) .update(issues)
.set({ ...data, updatedAt: new Date() }) .set(patch)
.where(eq(issues.id, id)) .where(eq(issues.id, id))
.returning() .returning()
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null);
},
remove: (id: string) => remove: (id: string) =>
db db
@@ -34,5 +121,116 @@ export function issueService(db: Db) {
.where(eq(issues.id, id)) .where(eq(issues.id, id))
.returning() .returning()
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
checkout: async (id: string, agentId: string, expectedStatuses: string[]) => {
const now = new Date();
const updated = await db
.update(issues)
.set({
assigneeAgentId: agentId,
status: "in_progress",
startedAt: now,
updatedAt: now,
})
.where(
and(
eq(issues.id, id),
inArray(issues.status, expectedStatuses),
or(isNull(issues.assigneeAgentId), eq(issues.assigneeAgentId, agentId)),
),
)
.returning()
.then((rows) => rows[0] ?? null);
if (updated) return updated;
const current = await db
.select({
id: issues.id,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
})
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!current) throw notFound("Issue not found");
throw conflict("Issue checkout conflict", {
issueId: current.id,
status: current.status,
assigneeAgentId: current.assigneeAgentId,
});
},
release: async (id: string, actorAgentId?: string) => {
const existing = await db
.select()
.from(issues)
.where(eq(issues.id, id))
.then((rows) => rows[0] ?? null);
if (!existing) return null;
if (actorAgentId && existing.assigneeAgentId && existing.assigneeAgentId !== actorAgentId) {
throw conflict("Only assignee can release issue");
}
return db
.update(issues)
.set({
status: "todo",
assigneeAgentId: null,
updatedAt: new Date(),
})
.where(eq(issues.id, id))
.returning()
.then((rows) => rows[0] ?? null);
},
listComments: (issueId: string) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issueId))
.orderBy(desc(issueComments.createdAt)),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
.select({ companyId: issues.companyId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
return db
.insert(issueComments)
.values({
companyId: issue.companyId,
issueId,
authorAgentId: actor.agentId ?? null,
authorUserId: actor.userId ?? null,
body,
})
.returning()
.then((rows) => rows[0]);
},
staleCount: async (companyId: string, minutes = 60) => {
const cutoff = new Date(Date.now() - minutes * 60 * 1000);
const result = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.status, "in_progress"),
sql`${issues.startedAt} < ${cutoff.toISOString()}`,
),
)
.then((rows) => rows[0]);
return Number(result?.count ?? 0);
},
}; };
} }

View File

@@ -4,7 +4,7 @@ import { projects } from "@paperclip/db";
export function projectService(db: Db) { export function projectService(db: Db) {
return { return {
list: () => db.select().from(projects), list: (companyId: string) => db.select().from(projects).where(eq(projects.companyId, companyId)),
getById: (id: string) => getById: (id: string) =>
db db
@@ -13,10 +13,10 @@ export function projectService(db: Db) {
.where(eq(projects.id, id)) .where(eq(projects.id, id))
.then((rows) => rows[0] ?? null), .then((rows) => rows[0] ?? null),
create: (data: typeof projects.$inferInsert) => create: (companyId: string, data: Omit<typeof projects.$inferInsert, "companyId">) =>
db db
.insert(projects) .insert(projects)
.values(data) .values({ ...data, companyId })
.returning() .returning()
.then((rows) => rows[0]), .then((rows) => rows[0]),

15
server/src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export {};
declare global {
namespace Express {
interface Request {
actor: {
type: "board" | "agent";
userId?: string;
agentId?: string;
companyId?: string;
keyId?: string;
};
}
}
}