diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9a043b9060b..0fbc3aa4e6b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -918,6 +918,7 @@ export const PERMISSION_KEYS = [ "tools:manage_connections", "tools:manage_profiles", "tools:view_audit", + "audit:view_agent_actions", "tools:use", "tools:manage_runtime", "inbox:manage", diff --git a/server/src/__tests__/agent-action-audit-routes.test.ts b/server/src/__tests__/agent-action-audit-routes.test.ts new file mode 100644 index 00000000000..c846a8a0ec7 --- /dev/null +++ b/server/src/__tests__/agent-action-audit-routes.test.ts @@ -0,0 +1,310 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + companyMemberships, + createDb, + documents, + heartbeatRuns, + issueComments, + issueDocuments, + issues, + principalPermissionGrants, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +type Db = ReturnType; + +async function createApp(db: Db, actor: Express.Request["actor"]) { + const { activityRoutes } = await import("../routes/activity.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", activityRoutes(db)); + app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error.status ?? 500).json({ error: error.message ?? "Internal server error" }); + }); + return app; +} + +describePostgres("agent action audit routes", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-action-audit-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueComments); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => tempDb?.cleanup()); + + async function seed() { + const company = await db.insert(companies).values({ + name: "Audit Company", + issuePrefix: `AU${randomUUID().slice(0, 6).toUpperCase()}`, + }).returning().then((rows) => rows[0]!); + const [agent, otherAgent] = await db.insert(agents).values([1, 2].map((index) => ({ + companyId: company.id, + name: `Audit Agent ${index}`, + role: "engineer", + status: "active" as const, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }))).returning(); + const issue = await db.insert(issues).values({ + companyId: company.id, + identifier: `${company.issuePrefix}-1`, + title: "Audit target", + status: "todo", + priority: "medium", + }).returning().then((rows) => rows[0]!); + const comment = await db.insert(issueComments).values({ + companyId: company.id, + issueId: issue.id, + authorAgentId: agent.id, + body: "A useful comment excerpt for the audit feed.", + }).returning().then((rows) => rows[0]!); + const document = await db.insert(documents).values({ + companyId: company.id, + title: "Plan", + latestBody: "Plan body", + createdByAgentId: agent.id, + updatedByAgentId: agent.id, + }).returning().then((rows) => rows[0]!); + const issueDocument = await db.insert(issueDocuments).values({ + companyId: company.id, + issueId: issue.id, + documentId: document.id, + key: "plan", + }).returning().then((rows) => rows[0]!); + const run = await db.insert(heartbeatRuns).values({ + companyId: company.id, + agentId: agent.id, + responsibleUserId: "legacy-user", + }).returning().then((rows) => rows[0]!); + const base = new Date("2026-07-17T00:00:00.000Z"); + await db.insert(activityLog).values([ + { companyId: company.id, actorType: "agent", actorId: agent.id, action: "issue.comment.created", entityType: "issue_comment", entityId: comment.id, agentId: agent.id, runId: run.id, responsibleUserId: null, createdAt: new Date(base.getTime() + 3000) }, + { companyId: company.id, actorType: "system", actorId: "system", action: "issue.document.updated", entityType: "issue_document", entityId: issueDocument.id, agentId: agent.id, runId: run.id, responsibleUserId: "direct-user", createdAt: new Date(base.getTime() + 2000) }, + { companyId: company.id, actorType: "agent", actorId: otherAgent.id, action: "issue.updated", entityType: "issue", entityId: issue.id, agentId: otherAgent.id, responsibleUserId: "other-user", createdAt: new Date(base.getTime() + 1000) }, + ]); + return { company, agent, otherAgent, issue, comment, issueDocument, run }; + } + + it("denies agents and board users without the audit permission", async () => { + const { company, agent } = await seed(); + const agentResponse = await request(await createApp(db, { + type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt", + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(agentResponse.status).toBe(403); + + const boardResponse = await request(await createApp(db, { + type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(boardResponse.status).toBe(403); + expect(boardResponse.body.error).toContain("audit:view_agent_actions"); + }); + + it("returns a client error for invalid audit query parameters", async () => { + const { company } = await seed(); + 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?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"); + + const nonUuidCursor = Buffer.from(JSON.stringify({ + createdAt: "2026-07-17T00:00:00.000000Z", + id: "not-a-uuid", + }), "utf8").toString("base64url"); + const nonUuidCursorResponse = 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=${encodeURIComponent(nonUuidCursor)}`); + expect(nonUuidCursorResponse.status).toBe(400); + expect(nonUuidCursorResponse.body.error).toBe("Invalid audit cursor"); + }); + + it("preserves sub-millisecond cursor precision across pages", async () => { + const { company, agent } = await seed(); + await db.delete(activityLog); + const newerId = randomUUID(); + const olderId = randomUUID(); + await db.execute(sql` + insert into activity_log ( + id, company_id, actor_type, actor_id, action, entity_type, entity_id, agent_id, created_at + ) values + (${newerId}::uuid, ${company.id}::uuid, 'agent', ${agent.id}, 'audit.precision', 'company', ${company.id}, ${agent.id}::uuid, '2026-07-17T00:00:00.001900Z'::timestamptz), + (${olderId}::uuid, ${company.id}::uuid, 'agent', ${agent.id}, 'audit.precision', 'company', ${company.id}, ${agent.id}::uuid, '2026-07-17T00:00:00.001100Z'::timestamptz) + `); + + const app = await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + }); + const first = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?action=audit.precision&limit=1`); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.items.map((item: { id: string }) => item.id)).toEqual([newerId]); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + const second = await request(app).get( + `/api/companies/${company.id}/audit/agent-actions?action=audit.precision&limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`, + ); + expect(second.status, JSON.stringify(second.body)).toBe(200); + expect(second.body.items.map((item: { id: string }) => item.id)).toEqual([olderId]); + expect(second.body.nextCursor).toBeNull(); + }); + + it("paginates, filters, enriches entities, and falls back to the run responsible user", async () => { + const { company, agent, otherAgent, issue, comment, issueDocument, run } = await seed(); + const app = await createApp(db, { type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false }); + const first = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1`); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.items).toHaveLength(1); + expect(first.body.items[0].responsibleUserId).toBe("legacy-user"); + expect(first.body.items[0].entity.comment).toEqual({ id: comment.id, excerpt: "A useful comment excerpt for the audit feed." }); + expect(first.body.items[0].entity.issue).toMatchObject({ id: issue.id, identifier: issue.identifier, title: issue.title }); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + const second = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`); + expect(second.body.items[0].entity.document).toEqual({ id: expect.any(String), key: "plan" }); + + const cases = [ + [`agentId=${agent.id}`, 2], + ["responsibleUserId=legacy-user", 1], + [`runId=${run.id}`, 2], + ["entityType=issue_document", 1], + [`entityId=${issueDocument.id}`, 1], + ["action=issue.comment", 1], + ["actorType=system", 1], + ["from=2026-07-17T00%3A00%3A01.500Z&to=2026-07-17T00%3A00%3A02.500Z", 1], + [`agentId=${otherAgent.id}`, 1], + ] as const; + for (const [query, count] of cases) { + const response = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?${query}`); + expect(response.status, `${query}: ${JSON.stringify(response.body)}`).toBe(200); + expect(response.body.items, query).toHaveLength(count); + } + }); + + 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, hiddenDocumentActivity, mismatchedActivity] = await db.insert(activityLog).values([ + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "issue.comment.created", + entityType: "issue", + entityId: hiddenIssue.id, + agentId: agent.id, + runId: run.id, + details: { + commentId: hiddenComment.id, + bodySnippet: hiddenComment.body, + identifier: hiddenIssue.identifier, + issueTitle: hiddenIssue.title, + }, + }, + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "issue.document_updated", + entityType: "issue", + entityId: hiddenIssue.id, + agentId: agent.id, + runId: run.id, + details: { + documentId: randomUUID(), + key: "plan", + title: "Hidden document title", + }, + }, + { + 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 === hiddenActivity.id)?.details).toBeNull(); + expect(response.body.items.find((item: { id: string }) => item.id === hiddenDocumentActivity.id)?.details).toBeNull(); + 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({ + companyId: company.id, principalType: "user", principalId: "reader", status: "active", membershipRole: "viewer", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, principalType: "user", principalId: "reader", permissionKey: "audit:view_agent_actions", scope: null, grantedByUserId: null, + }); + const response = await request(await createApp(db, { + type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.items).toHaveLength(3); + }); +}); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 7fe6894cbf8..4911d2c3ff7 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -7,6 +7,8 @@ import { activityService, normalizeActivityLimit } from "../services/activity.js import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js"; import { accessService, heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; +import { badRequest, forbidden } from "../errors.js"; +import { agentActionAuditService } from "../services/agent-action-audit.js"; const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), @@ -18,12 +20,35 @@ const createActivitySchema = z.object({ details: z.record(z.unknown()).optional().nullable(), }); +const agentActionAuditQuerySchema = z.object({ + agentId: z.string().uuid().optional(), + responsibleUserId: z.string().min(1).optional(), + runId: z.string().uuid().optional(), + entityType: z.string().min(1).optional(), + entityId: z.string().min(1).optional(), + action: z.string().min(1).optional(), + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); const access = accessService(db); const heartbeat = heartbeatService(db); const issueSvc = issueService(db); + const agentAudit = agentActionAuditService(db); + + async function assertAgentAuditPermission(req: import("express").Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + if (req.actor.userId && await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions")) return; + throw forbidden("Missing permission: audit:view_agent_actions"); + } async function assertCompanyScopeReadAllowed(req: Parameters[0], res: any, companyId: string) { const decision = await access.decide({ @@ -88,6 +113,16 @@ export function activityRoutes(db: Db) { res.json(result); }); + router.get("/companies/:companyId/audit/agent-actions", async (req, res) => { + const companyId = req.params.companyId as string; + await assertAgentAuditPermission(req, companyId); + const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + throw badRequest("Invalid agent action audit query", parsedQuery.error.issues); + } + res.json(await agentAudit.list({ companyId, ...parsedQuery.data })); + }); + router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5a4c5c7d367..b6272b6ec6f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2885,6 +2885,30 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/audit/agent-actions", + tags: ["activity"], + summary: "List agent action audit entries", + request: { + params: z.object({ companyId: z.string() }), + query: z.object({ + agentId: z.string().uuid().optional(), + responsibleUserId: z.string().min(1).optional(), + runId: z.string().uuid().optional(), + entityType: z.string().min(1).optional(), + entityId: z.string().min(1).optional(), + action: z.string().min(1).optional(), + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + from: z.string().datetime().optional(), + to: z.string().datetime().optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/activity", diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index fb27951a5c8..981d7ce153f 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -65,6 +65,20 @@ export interface LogActivityInput { details?: Record | null; } +export async function createActivityDetailsRedactor(db: Db) { + const currentUserRedactionOptions = { + enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs, + }; + return (details: Record | null) => ( + details ? redactCurrentUserValue(sanitizeRecord(details), currentUserRedactionOptions) : null + ); +} + +export async function redactActivityDetails(db: Db, details: Record | null) { + if (!details) return null; + return (await createActivityDetailsRedactor(db))(details); +} + function readNonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -125,13 +139,7 @@ export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActi } export async function logActivity(db: Db, input: LogActivityInput) { - const currentUserRedactionOptions = { - enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs, - }; - const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null; - const redactedDetails = sanitizedDetails - ? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions) - : null; + const redactedDetails = await redactActivityDetails(db, input.details ?? null); const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input); await db.insert(activityLog).values({ companyId: input.companyId, diff --git a/server/src/services/agent-action-audit.ts b/server/src/services/agent-action-audit.ts new file mode 100644 index 00000000000..864b48dd74b --- /dev/null +++ b/server/src/services/agent-action-audit.ts @@ -0,0 +1,193 @@ +import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"; +import { z } from "zod"; +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; + agentId?: string; + responsibleUserId?: string; + runId?: string; + entityType?: string; + entityId?: string; + action?: string; + actorType?: "agent" | "user" | "system" | "plugin"; + from?: Date; + to?: Date; + cursor?: string; + limit: number; +} + +type CursorValue = { createdAt: string; id: string }; + +const cursorValueSchema = z.object({ + createdAt: z.string().datetime({ offset: true }), + id: z.string().uuid(), +}); + +function decodeCursor(cursor: string | undefined): CursorValue | null { + if (!cursor) return null; + try { + const parsed = cursorValueSchema.safeParse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +function encodeCursor(value: CursorValue) { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function excerpt(value: string, maxLength = 280) { + const normalized = value.replace(/\s+/g, " ").trim(); + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`; +} + +export function agentActionAuditService(db: Db) { + return { + list: async (filters: AgentActionAuditFilters) => { + const cursor = decodeCursor(filters.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)); + if (filters.responsibleUserId) conditions.push(or( + eq(activityLog.responsibleUserId, filters.responsibleUserId), + and( + isNull(activityLog.responsibleUserId), + eq(heartbeatRuns.responsibleUserId, filters.responsibleUserId), + ), + )!); + if (filters.runId) conditions.push(eq(activityLog.runId, filters.runId)); + if (filters.entityType) conditions.push(eq(activityLog.entityType, filters.entityType)); + if (filters.entityId) conditions.push(eq(activityLog.entityId, filters.entityId)); + if (filters.action) conditions.push(sql`starts_with(${activityLog.action}, ${filters.action})`); + if (filters.actorType) conditions.push(eq(activityLog.actorType, filters.actorType)); + if (filters.from) conditions.push(gte(activityLog.createdAt, filters.from)); + if (filters.to) conditions.push(lte(activityLog.createdAt, filters.to)); + if (cursor) { + conditions.push(or( + sql`${activityLog.createdAt} < ${cursor.createdAt}::timestamptz`, + and( + sql`${activityLog.createdAt} = ${cursor.createdAt}::timestamptz`, + lt(activityLog.id, cursor.id), + ), + )!); + } + + const rows = await db.select({ + id: activityLog.id, + companyId: activityLog.companyId, + actorType: activityLog.actorType, + actorId: activityLog.actorId, + action: activityLog.action, + entityType: activityLog.entityType, + entityId: activityLog.entityId, + agentId: activityLog.agentId, + runId: activityLog.runId, + responsibleUserId: effectiveResponsibleUserId, + details: activityLog.details, + createdAt: activityLog.createdAt, + cursorCreatedAt: sql`to_char(${activityLog.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`.as("cursor_created_at"), + }).from(activityLog).leftJoin(heartbeatRuns, and( + eq(heartbeatRuns.companyId, activityLog.companyId), + eq(heartbeatRuns.id, activityLog.runId), + )).where(and(...conditions)).orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(filters.limit + 1); + + const page = rows.slice(0, filters.limit); + 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), + 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), + 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), + 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])); + const documents = new Map(); + for (const row of documentRows) { + documents.set(row.id, row); + documents.set(row.documentId, row); + } + + const redactDetails = await createActivityDetailsRedactor(db); + const items = page.map((row) => { + 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 + ? { id: document.issueId, identifier: document.identifier, title: document.title } + : issue ? { id: issue.id, identifier: issue.identifier, title: issue.title } : null; + const isIssueDerived = row.entityType === "issue" + || row.entityType === "issue_comment" + || row.entityType === "issue_document"; + return { + id: row.id, + companyId: row.companyId, + actorType: row.actorType, + actorId: row.actorId, + action: row.action, + entityType: row.entityType, + entityId: row.entityId, + agentId: row.agentId, + runId: row.runId, + responsibleUserId: row.responsibleUserId, + createdAt: row.createdAt, + details: isIssueDerived && !issueSnippet ? null : redactDetails(row.details), + entity: { + issue: issueSnippet, + comment: comment ? { id: comment.id, excerpt: excerpt(comment.body) } : null, + document: document ? { id: document.documentId, key: document.key } : null, + }, + }; + }); + const last = page.at(-1); + return { + items, + nextCursor: rows.length > filters.limit && last + ? encodeCursor({ createdAt: last.cursorCreatedAt, id: last.id }) + : null, + }; + }, + }; +}