import { Router } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { normalizeIssueIdentifier } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { activityService, normalizeActivityLimit } from "../services/activity.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; import { accessService, heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), actorId: z.string().min(1), action: z.string().min(1), entityType: z.string().min(1), entityId: z.string().min(1), agentId: z.string().uuid().optional().nullable(), details: z.record(z.unknown()).optional().nullable(), }); export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); const access = accessService(db); const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function assertCompanyScopeReadAllowed(req: Parameters[0], res: any, companyId: string) { const decision = await access.decide({ actor: req.actor, action: "company_scope:read", resource: { type: "company", companyId }, }); if (decision.allowed) return true; res.status(403).json({ error: "Activity is outside this actor's authorization boundary" }); return false; } async function assertIssueReadAllowed(req: Parameters[0], res: any, issue: { id: string; companyId: string; projectId: string | null; parentId: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; status: string; }) { const decision = await access.decide({ actor: req.actor, action: "issue:read", resource: { type: "issue", companyId: issue.companyId, issueId: issue.id, projectId: issue.projectId, parentIssueId: issue.parentId, assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status, }, }); if (decision.allowed) return true; res.status(403).json({ error: "Issue activity is outside this actor's authorization boundary" }); return false; } async function resolveIssueByRef(rawId: string) { const identifier = normalizeIssueIdentifier(rawId); if (identifier) { return issueSvc.getByIdentifier(identifier); } return issueSvc.getById(rawId); } router.get("/companies/:companyId/activity", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return; const filters = { companyId, agentId: req.query.agentId as string | undefined, entityType: req.query.entityType as string | undefined, entityId: req.query.entityId as string | undefined, limit: normalizeActivityLimit(Number(req.query.limit)), }; const result = await svc.list(filters); res.json(result); }); router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, details: req.body.details ? sanitizeRecord(req.body.details) : null, }); res.status(201).json(event); }); router.get("/issues/:id/activity", async (req, res) => { const rawId = req.params.id as string; const issue = await resolveIssueByRef(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertIssueReadAllowed(req, res, issue))) return; const result = await svc.forIssue(issue.id); res.json(result); }); router.get("/issues/:id/runs", async (req, res) => { const rawId = req.params.id as string; const issue = await resolveIssueByRef(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; } assertCompanyAccess(req, issue.companyId); if (!(await assertIssueReadAllowed(req, res, issue))) return; const result = await svc.runsForIssue(issue.companyId, issue.id); res.json(result); }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => { assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { res.json([]); return; } assertCompanyAccess(req, run.companyId); if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return; const result = await svc.issuesForRun(runId); res.json(result); }); return router; }