Backend: - Add router.param middleware in issues, activity, and agents routes to resolve identifiers (e.g. PAP-39) to UUIDs before handlers run - Simplify GET /issues/:id now that param middleware handles resolution - Include identifier in getAncestors response and issuesForRun query - Add identifier field to IssueAncestor shared type Frontend: - Update all issue navigation links across 15+ files to use issue.identifier ?? issue.id instead of bare UUIDs - Add URL redirect in IssueDetail: navigating via UUID automatically replaces the URL with the human-readable identifier - Fix childIssues filter to use issue.id (UUID) instead of URL param so it works correctly with identifier-based URLs - Add issueUrl() utility in lib/utils.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
import { and, desc, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclip/db";
|
|
import { activityLog, heartbeatRuns, issues } from "@paperclip/db";
|
|
|
|
export interface ActivityFilters {
|
|
companyId: string;
|
|
agentId?: string;
|
|
entityType?: string;
|
|
entityId?: string;
|
|
}
|
|
|
|
export function activityService(db: Db) {
|
|
const issueIdAsText = sql<string>`${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: (issueId: string) =>
|
|
db
|
|
.selectDistinctOn([activityLog.runId], {
|
|
runId: activityLog.runId,
|
|
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(activityLog)
|
|
.innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id))
|
|
.where(
|
|
and(
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(activityLog.entityId, issueId),
|
|
isNotNull(activityLog.runId),
|
|
),
|
|
)
|
|
.orderBy(activityLog.runId, desc(heartbeatRuns.createdAt)),
|
|
|
|
issuesForRun: (runId: string) =>
|
|
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.runId, runId),
|
|
eq(activityLog.entityType, "issue"),
|
|
isNull(issues.hiddenAt),
|
|
),
|
|
)
|
|
.orderBy(issueIdAsText),
|
|
|
|
create: (data: typeof activityLog.$inferInsert) =>
|
|
db
|
|
.insert(activityLog)
|
|
.values(data)
|
|
.returning()
|
|
.then((rows) => rows[0]),
|
|
};
|
|
}
|