diff --git a/server/src/__tests__/agent-action-audit-routes.test.ts b/server/src/__tests__/agent-action-audit-routes.test.ts index 0dc49d239d2..ce6703c2bb4 100644 --- a/server/src/__tests__/agent-action-audit-routes.test.ts +++ b/server/src/__tests__/agent-action-audit-routes.test.ts @@ -135,6 +135,12 @@ describePostgres("agent action audit routes", () => { })).get(`/api/companies/${company.id}/audit/agent-actions?limit=invalid`); expect(response.status).toBe(400); expect(response.body.error).toBe("Invalid agent action audit query"); + + const cursorResponse = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions?cursor=invalid`); + expect(cursorResponse.status).toBe(400); + expect(cursorResponse.body.error).toBe("Invalid audit cursor"); }); it("paginates, filters, enriches entities, and falls back to the run responsible user", async () => { @@ -169,6 +175,61 @@ describePostgres("agent action audit routes", () => { } }); + it("does not enrich hidden issues or mismatched entity types", async () => { + const { company, agent, comment, run } = await seed(); + const hiddenIssue = await db.insert(issues).values({ + companyId: company.id, + identifier: `${company.issuePrefix}-HIDDEN`, + title: "Hidden audit target", + status: "todo", + priority: "medium", + hiddenAt: new Date(), + }).returning().then((rows) => rows[0]!); + const hiddenComment = await db.insert(issueComments).values({ + companyId: company.id, + issueId: hiddenIssue.id, + authorAgentId: agent.id, + body: "Hidden audit comment", + }).returning().then((rows) => rows[0]!); + const [hiddenActivity, mismatchedActivity] = await db.insert(activityLog).values([ + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "issue.comment.created", + entityType: "issue_comment", + entityId: hiddenComment.id, + agentId: agent.id, + runId: run.id, + }, + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "company.updated", + entityType: "company", + entityId: comment.id, + agentId: agent.id, + runId: run.id, + }, + ]).returning(); + + const response = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.items.find((item: { id: string }) => item.id === hiddenActivity.id)?.entity).toEqual({ + issue: null, + comment: null, + document: null, + }); + expect(response.body.items.find((item: { id: string }) => item.id === mismatchedActivity.id)?.entity).toEqual({ + issue: null, + comment: null, + document: null, + }); + }); + it("allows a signed-in board user with the explicit permission", async () => { const { company } = await seed(); await db.insert(companyMemberships).values({ diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index c3dc2951cd9..4911d2c3ff7 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -120,15 +120,7 @@ export function activityRoutes(db: Db) { if (!parsedQuery.success) { throw badRequest("Invalid agent action audit query", parsedQuery.error.issues); } - try { - res.json(await agentAudit.list({ companyId, ...parsedQuery.data })); - } catch (error) { - if (error instanceof Error && error.message === "Invalid audit cursor") { - res.status(400).json({ error: error.message }); - return; - } - throw error; - } + res.json(await agentAudit.list({ companyId, ...parsedQuery.data })); }); router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { diff --git a/server/src/services/agent-action-audit.ts b/server/src/services/agent-action-audit.ts index 93f812b458e..b8dfbf65d20 100644 --- a/server/src/services/agent-action-audit.ts +++ b/server/src/services/agent-action-audit.ts @@ -2,6 +2,8 @@ import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from import type { Db } from "@paperclipai/db"; import { activityLog, heartbeatRuns, issueComments, issueDocuments, issues } from "@paperclipai/db"; import { createActivityDetailsRedactor } from "./activity-log.js"; +import { badRequest } from "../errors.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; export interface AgentActionAuditFilters { companyId: string; @@ -45,7 +47,7 @@ export function agentActionAuditService(db: Db) { return { list: async (filters: AgentActionAuditFilters) => { const cursor = decodeCursor(filters.cursor); - if (filters.cursor && !cursor) throw new Error("Invalid audit cursor"); + if (filters.cursor && !cursor) throw badRequest("Invalid audit cursor"); const effectiveResponsibleUserId = sql`coalesce(${activityLog.responsibleUserId}, ${heartbeatRuns.responsibleUserId})`; const conditions = [eq(activityLog.companyId, filters.companyId), isNotNull(activityLog.agentId)]; if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId)); @@ -90,21 +92,46 @@ export function agentActionAuditService(db: Db) { )).where(and(...conditions)).orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(filters.limit + 1); const page = rows.slice(0, filters.limit); - const entityIds = [...new Set(page.map((row) => row.entityId))]; - const commentRows = entityIds.length === 0 ? [] : await db.select({ + const commentEntityIds = [...new Set(page + .filter((row) => row.entityType === "issue_comment") + .map((row) => row.entityId))]; + const issueEntityIds = [...new Set(page + .filter((row) => row.entityType === "issue") + .map((row) => row.entityId))]; + const documentEntityIds = [...new Set(page + .filter((row) => row.entityType === "issue_document") + .map((row) => row.entityId))]; + const commentRows = commentEntityIds.length === 0 ? [] : await db.select({ id: issueComments.id, body: issueComments.body, issueId: issues.id, identifier: issues.identifier, title: issues.title, - }).from(issueComments).innerJoin(issues, and(eq(issues.id, issueComments.issueId), eq(issues.companyId, filters.companyId))) - .where(and(eq(issueComments.companyId, filters.companyId), inArray(issueComments.id, entityIds))); - const issueRows = entityIds.length === 0 ? [] : await db.select({ + }).from(issueComments).innerJoin(issues, and( + eq(issues.id, issueComments.issueId), + eq(issues.companyId, filters.companyId), + visibleIssueCondition(), + )).where(and( + eq(issueComments.companyId, filters.companyId), + inArray(issueComments.id, commentEntityIds), + )); + const issueRows = issueEntityIds.length === 0 ? [] : await db.select({ id: issues.id, identifier: issues.identifier, title: issues.title, - }).from(issues).where(and(eq(issues.companyId, filters.companyId), inArray(issues.id, entityIds))); - const documentRows = entityIds.length === 0 ? [] : await db.select({ + }).from(issues).where(and( + eq(issues.companyId, filters.companyId), + visibleIssueCondition(), + inArray(issues.id, issueEntityIds), + )); + const documentRows = documentEntityIds.length === 0 ? [] : await db.select({ id: issueDocuments.id, documentId: issueDocuments.documentId, key: issueDocuments.key, issueId: issues.id, identifier: issues.identifier, title: issues.title, - }).from(issueDocuments).innerJoin(issues, and(eq(issues.id, issueDocuments.issueId), eq(issues.companyId, filters.companyId))) - .where(and(eq(issueDocuments.companyId, filters.companyId), or( - inArray(issueDocuments.id, entityIds), inArray(issueDocuments.documentId, entityIds), - ))); + }).from(issueDocuments).innerJoin(issues, and( + eq(issues.id, issueDocuments.issueId), + eq(issues.companyId, filters.companyId), + visibleIssueCondition(), + )).where(and( + eq(issueDocuments.companyId, filters.companyId), + or( + inArray(issueDocuments.id, documentEntityIds), + inArray(issueDocuments.documentId, documentEntityIds), + ), + )); const comments = new Map(commentRows.map((row) => [row.id, row])); const issueMap = new Map(issueRows.map((row) => [row.id, row])); @@ -116,9 +143,9 @@ export function agentActionAuditService(db: Db) { const redactDetails = await createActivityDetailsRedactor(db); const items = page.map((row) => { - const comment = comments.get(row.entityId); - const issue = issueMap.get(row.entityId); - const document = documents.get(row.entityId); + const comment = row.entityType === "issue_comment" ? comments.get(row.entityId) : undefined; + const issue = row.entityType === "issue" ? issueMap.get(row.entityId) : undefined; + const document = row.entityType === "issue_document" ? documents.get(row.entityId) : undefined; const issueSnippet = comment ? { id: comment.issueId, identifier: comment.identifier, title: comment.title } : document