import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, heartbeatRuns, issues } from "@paperclipai/db"; export interface ActivityFilters { companyId: string; agentId?: string; entityType?: string; entityId?: string; } export function activityService(db: Db) { const issueIdAsText = sql`${issues.id}::text`; return { list: (filters: ActivityFilters) => { const conditions = [eq(activityLog.companyId, filters.companyId)]; if (filters.agentId) { conditions.push(eq(activityLog.agentId, filters.agentId)); } if (filters.entityType) { conditions.push(eq(activityLog.entityType, filters.entityType)); } if (filters.entityId) { conditions.push(eq(activityLog.entityId, filters.entityId)); } return db .select({ activityLog }) .from(activityLog) .leftJoin( issues, and( eq(activityLog.entityType, sql`'issue'`), eq(activityLog.entityId, issueIdAsText), ), ) .where( and( ...conditions, or( sql`${activityLog.entityType} != 'issue'`, isNull(issues.hiddenAt), ), ), ) .orderBy(desc(activityLog.createdAt)) .then((rows) => rows.map((r) => r.activityLog)); }, forIssue: (issueId: string) => db .select() .from(activityLog) .where( and( eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issueId), ), ) .orderBy(desc(activityLog.createdAt)), runsForIssue: (companyId: string, issueId: string) => db .select({ runId: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, usageJson: heartbeatRuns.usageJson, resultJson: heartbeatRuns.resultJson, }) .from(heartbeatRuns) .where( and( eq(heartbeatRuns.companyId, companyId), or( sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, sql`exists ( select 1 from ${activityLog} where ${activityLog.companyId} = ${companyId} and ${activityLog.entityType} = 'issue' and ${activityLog.entityId} = ${issueId} and ${activityLog.runId} = ${heartbeatRuns.id} )`, ), ), ) .orderBy(desc(heartbeatRuns.createdAt)), issuesForRun: async (runId: string) => { const run = await db .select({ companyId: heartbeatRuns.companyId, contextSnapshot: heartbeatRuns.contextSnapshot, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) .then((rows) => rows[0] ?? null); if (!run) return []; const fromActivity = await db .selectDistinctOn([issueIdAsText], { issueId: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, }) .from(activityLog) .innerJoin(issues, eq(activityLog.entityId, issueIdAsText)) .where( and( eq(activityLog.companyId, run.companyId), eq(activityLog.runId, runId), eq(activityLog.entityType, "issue"), isNull(issues.hiddenAt), ), ) .orderBy(issueIdAsText); const context = run.contextSnapshot; const contextIssueId = context && typeof context === "object" && typeof (context as Record).issueId === "string" ? ((context as Record).issueId as string) : null; if (!contextIssueId) return fromActivity; if (fromActivity.some((issue) => issue.issueId === contextIssueId)) return fromActivity; const fromContext = await db .select({ issueId: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, priority: issues.priority, }) .from(issues) .where( and( eq(issues.companyId, run.companyId), eq(issues.id, contextIssueId), isNull(issues.hiddenAt), ), ) .then((rows) => rows[0] ?? null); if (!fromContext) return fromActivity; return [fromContext, ...fromActivity]; }, create: (data: typeof activityLog.$inferInsert) => db .insert(activityLog) .values(data) .returning() .then((rows) => rows[0]), }; }