diff --git a/package.json b/package.json index a6a77fca..cb6e094a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "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:ui": "pnpm --filter @paperclip/ui dev", "build": "pnpm -r build", diff --git a/server/src/app.ts b/server/src/app.ts index 719fea74..b13f08e0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,27 +3,37 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Db } from "@paperclip/db"; import { httpLogger, errorHandler } from "./middleware/index.js"; +import { actorMiddleware } from "./middleware/auth.js"; import { healthRoutes } from "./routes/health.js"; +import { companyRoutes } from "./routes/companies.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.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 { dashboardRoutes } from "./routes/dashboard.js"; export function createApp(db: Db, opts: { serveUi: boolean }) { const app = express(); app.use(express.json()); app.use(httpLogger); + app.use(actorMiddleware(db)); // Mount API routes const api = Router(); api.use("/health", healthRoutes()); - api.use("/agents", agentRoutes(db)); - api.use("/projects", projectRoutes(db)); - api.use("/issues", issueRoutes(db)); - api.use("/goals", goalRoutes(db)); - api.use("/activity", activityRoutes(db)); + api.use("/companies", companyRoutes(db)); + api.use(agentRoutes(db)); + api.use(projectRoutes(db)); + api.use(issueRoutes(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); // SPA fallback for serving the UI build diff --git a/server/src/errors.ts b/server/src/errors.ts new file mode 100644 index 00000000..8ad9b578 --- /dev/null +++ b/server/src/errors.ts @@ -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); +} diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 00000000..cce6f6b6 --- /dev/null +++ b/server/src/middleware/auth.ts @@ -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"; +} diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index cb4d4fbf..1f5433cb 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction } from "express"; import { ZodError } from "zod"; import { logger } from "./logger.js"; +import { HttpError } from "../errors.js"; export function errorHandler( err: unknown, @@ -8,6 +9,14 @@ export function errorHandler( res: Response, _next: NextFunction, ) { + if (err instanceof HttpError) { + res.status(err.status).json({ + error: err.message, + ...(err.details ? { details: err.details } : {}), + }); + return; + } + if (err instanceof ZodError) { res.status(400).json({ error: "Validation error", details: err.errors }); return; diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0f614023..84a467f0 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -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); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 0d3184b9..d7d3a893 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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; } diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts new file mode 100644 index 00000000..3e4c998a --- /dev/null +++ b/server/src/routes/approvals.ts @@ -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; +} diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts new file mode 100644 index 00000000..5f5eef91 --- /dev/null +++ b/server/src/routes/authz.ts @@ -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, + }; +} diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts new file mode 100644 index 00000000..0ef8cd09 --- /dev/null +++ b/server/src/routes/companies.ts @@ -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; +} diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts new file mode 100644 index 00000000..00b14213 --- /dev/null +++ b/server/src/routes/costs.ts @@ -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; +} diff --git a/server/src/routes/dashboard.ts b/server/src/routes/dashboard.ts new file mode 100644 index 00000000..30709716 --- /dev/null +++ b/server/src/routes/dashboard.ts @@ -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; +} diff --git a/server/src/routes/goals.ts b/server/src/routes/goals.ts index e673235d..ee13f978 100644 --- a/server/src/routes/goals.ts +++ b/server/src/routes/goals.ts @@ -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); }); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index a78ecd2a..70300770 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -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"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 04a33e9d..50efb487 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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; } diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 63ab47e7..fa9e08f8 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -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); }); diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts new file mode 100644 index 00000000..4ebb180d --- /dev/null +++ b/server/src/services/activity-log.ts @@ -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 | 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, + }); +} diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 4f59b652..760c973a 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -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 { activityLog } from "@paperclip/db"; export interface ActivityFilters { + companyId: string; agentId?: string; entityType?: string; entityId?: string; @@ -10,26 +11,20 @@ export interface ActivityFilters { export function activityService(db: Db) { return { - list: (filters?: ActivityFilters) => { - const conditions = []; + list: (filters: ActivityFilters) => { + const conditions = [eq(activityLog.companyId, filters.companyId)]; - if (filters?.agentId) { + if (filters.agentId) { conditions.push(eq(activityLog.agentId, filters.agentId)); } - if (filters?.entityType) { + if (filters.entityType) { conditions.push(eq(activityLog.entityType, filters.entityType)); } - if (filters?.entityId) { + if (filters.entityId) { conditions.push(eq(activityLog.entityId, filters.entityId)); } - const query = db.select().from(activityLog); - - if (conditions.length > 0) { - return query.where(and(...conditions)).orderBy(desc(activityLog.createdAt)); - } - - return query.orderBy(desc(activityLog.createdAt)); + return db.select().from(activityLog).where(and(...conditions)).orderBy(desc(activityLog.createdAt)); }, create: (data: typeof activityLog.$inferInsert) => diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index ce3a0b67..fb0d807a 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -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 { 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) { + 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 { - list: () => db.select().from(agents), + list: (companyId: string) => + db.select().from(agents).where(eq(agents.companyId, companyId)), - getById: (id: string) => - db - .select() - .from(agents) - .where(eq(agents.id, id)) - .then((rows) => rows[0] ?? null), + getById, - create: (data: typeof agents.$inferInsert) => - db + create: async (companyId: string, data: Omit) => { + if (data.reportsTo) { + await ensureManager(companyId, data.reportsTo); + } + + const created = await db .insert(agents) - .values(data) + .values({ ...data, companyId }) .returning() - .then((rows) => rows[0]), + .then((rows) => rows[0]); - update: (id: string, data: Partial) => - db + return created; + }, + + update: async (id: string, data: Partial) => { + 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) .set({ ...data, updatedAt: new Date() }) .where(eq(agents.id, id)) .returning() - .then((rows) => rows[0] ?? null), + .then((rows) => rows[0] ?? null); + }, - remove: (id: string) => - db - .delete(agents) + pause: async (id: string) => { + const existing = await getById(id); + 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)) .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(); + 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> => { + 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"]))), }; } diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts new file mode 100644 index 00000000..83675459 --- /dev/null +++ b/server/src/services/approvals.ts @@ -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) => + 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; + 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) + : {}, + 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) + : 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]); + }, + }; +} diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts new file mode 100644 index 00000000..469c493f --- /dev/null +++ b/server/src/services/companies.ts @@ -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) => + 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), + }; +} diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts new file mode 100644 index 00000000..64ce31af --- /dev/null +++ b/server/src/services/costs.ts @@ -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) => { + 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`coalesce(sum(${costEvents.costCents}), 0)`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)`, + outputTokens: sql`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`coalesce(sum(${costEvents.costCents}), 0)`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)`, + outputTokens: sql`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)`)), + }; +} diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts new file mode 100644 index 00000000..30133518 --- /dev/null +++ b/server/src/services/dashboard.ts @@ -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`count(*)` }) + .from(agents) + .where(eq(agents.companyId, companyId)) + .groupBy(agents.status); + + const taskRows = await db + .select({ status: issues.status, count: sql`count(*)` }) + .from(issues) + .where(eq(issues.companyId, companyId)) + .groupBy(issues.status); + + const pendingApprovals = await db + .select({ count: sql`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`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 = { + active: 0, + running: 0, + paused: 0, + error: 0, + }; + for (const row of agentRows) { + agentCounts[row.status] = Number(row.count); + } + + const taskCounts: Record = { + 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, + }; + }, + }; +} diff --git a/server/src/services/goals.ts b/server/src/services/goals.ts index 32c5e0a0..2de962ee 100644 --- a/server/src/services/goals.ts +++ b/server/src/services/goals.ts @@ -4,7 +4,7 @@ import { goals } from "@paperclip/db"; export function goalService(db: Db) { return { - list: () => db.select().from(goals), + list: (companyId: string) => db.select().from(goals).where(eq(goals.companyId, companyId)), getById: (id: string) => db @@ -13,10 +13,10 @@ export function goalService(db: Db) { .where(eq(goals.id, id)) .then((rows) => rows[0] ?? null), - create: (data: typeof goals.$inferInsert) => + create: (companyId: string, data: Omit) => db .insert(goals) - .values(data) + .values({ ...data, companyId }) .returning() .then((rows) => rows[0]), diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts new file mode 100644 index 00000000..5a395811 --- /dev/null +++ b/server/src/services/heartbeat.ts @@ -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(); + +function parseObject(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +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, + ) { + 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, context: Record) { + 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; + 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, + ) { + 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 = {}; + 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((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; + + 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 = {}, + ) => { + 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; + }, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ff6ab38e..d5cba30a 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,5 +1,11 @@ +export { companyService } from "./companies.js"; export { agentService } from "./agents.js"; export { projectService } from "./projects.js"; -export { issueService } from "./issues.js"; +export { issueService, type IssueFilters } from "./issues.js"; export { goalService } from "./goals.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"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 5a0fba53..e67d8229 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 { issues } from "@paperclip/db"; +import { issues, issueComments } from "@paperclip/db"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +const ISSUE_TRANSITIONS: Record = { + 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, +): Partial { + 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) { 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) => db @@ -13,20 +65,55 @@ export function issueService(db: Db) { .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null), - create: (data: typeof issues.$inferInsert) => - db - .insert(issues) - .values(data) - .returning() - .then((rows) => rows[0]), + create: (companyId: string, data: Omit) => { + const values = { ...data, companyId } as typeof issues.$inferInsert; + if (values.status === "in_progress" && !values.startedAt) { + values.startedAt = new Date(); + } + if (values.status === "done") { + values.completedAt = new Date(); + } + if (values.status === "cancelled") { + values.cancelledAt = new Date(); + } - update: (id: string, data: Partial) => - db + return db + .insert(issues) + .values(values) + .returning() + .then((rows) => rows[0]); + }, + + update: async (id: string, data: Partial) => { + 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 = { + ...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) - .set({ ...data, updatedAt: new Date() }) + .set(patch) .where(eq(issues.id, id)) .returning() - .then((rows) => rows[0] ?? null), + .then((rows) => rows[0] ?? null); + }, remove: (id: string) => db @@ -34,5 +121,116 @@ export function issueService(db: Db) { .where(eq(issues.id, id)) .returning() .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`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); + }, }; } diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index ff1608c3..4ada925f 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -4,7 +4,7 @@ import { projects } from "@paperclip/db"; export function projectService(db: Db) { return { - list: () => db.select().from(projects), + list: (companyId: string) => db.select().from(projects).where(eq(projects.companyId, companyId)), getById: (id: string) => db @@ -13,10 +13,10 @@ export function projectService(db: Db) { .where(eq(projects.id, id)) .then((rows) => rows[0] ?? null), - create: (data: typeof projects.$inferInsert) => + create: (companyId: string, data: Omit) => db .insert(projects) - .values(data) + .values({ ...data, companyId }) .returning() .then((rows) => rows[0]), diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts new file mode 100644 index 00000000..fe176623 --- /dev/null +++ b/server/src/types/express.d.ts @@ -0,0 +1,15 @@ +export {}; + +declare global { + namespace Express { + interface Request { + actor: { + type: "board" | "agent"; + userId?: string; + agentId?: string; + companyId?: string; + keyId?: string; + }; + } + } +}