mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
import { and, desc, eq, gte, isNull, sql } from "drizzle-orm";
|
|
import {
|
|
approvals,
|
|
heartbeatRuns,
|
|
issueApprovals,
|
|
issueThreadInteractions,
|
|
issueUserRecency,
|
|
issues,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import type { IssueStatus, IssueUserRecencyKind, RecentIssue } from "@paperclipai/shared";
|
|
import type { LogActivityInput } from "./activity-log.js";
|
|
|
|
export const RECENT_ISSUES_MAX_LIMIT = 25;
|
|
export const RECENT_ISSUES_WINDOW_DAYS = 30;
|
|
|
|
const EDIT_FIELDS = new Set(["status", "assigneeAgentId", "assigneeUserId", "priority", "title"]);
|
|
|
|
export function issueRecencyKindForActivity(
|
|
input: Pick<LogActivityInput, "actorType" | "action" | "entityType" | "details">,
|
|
): IssueUserRecencyKind | null {
|
|
if (input.actorType !== "user") return null;
|
|
if (input.entityType === "approval") {
|
|
return ["approval.approved", "approval.rejected", "approval.revision_requested"].includes(input.action)
|
|
? "approval"
|
|
: null;
|
|
}
|
|
if (input.entityType !== "issue") return null;
|
|
if (input.action === "issue.created") return "created";
|
|
if (input.action === "issue.comment_added") return "commented";
|
|
if ([
|
|
"issue.thread_interaction_accepted",
|
|
"issue.thread_interaction_rejected",
|
|
"issue.thread_interaction_answered",
|
|
"issue.thread_interaction_item_verdicts_submitted",
|
|
].includes(input.action)) return "interaction";
|
|
if (["issue.document_created", "issue.document_updated", "issue.document_restored"].includes(input.action)) {
|
|
return "document";
|
|
}
|
|
if (input.action !== "issue.updated") return null;
|
|
|
|
const details = input.details ?? {};
|
|
const changes = details.changes && typeof details.changes === "object" && !Array.isArray(details.changes)
|
|
? details.changes as Record<string, unknown>
|
|
: {};
|
|
return [...EDIT_FIELDS].some((field) => field in changes || field in details) ? "edited" : null;
|
|
}
|
|
|
|
export async function recordIssueUserRecency(
|
|
db: Db,
|
|
input: {
|
|
companyId: string;
|
|
userId: string;
|
|
issueIds: string[];
|
|
kind: IssueUserRecencyKind;
|
|
interactedAt?: Date;
|
|
},
|
|
) {
|
|
const issueIds = [...new Set(input.issueIds.filter(Boolean))];
|
|
if (issueIds.length === 0 || input.userId.trim().length === 0) return;
|
|
const interactedAt = input.interactedAt ?? new Date();
|
|
await db
|
|
.insert(issueUserRecency)
|
|
.values(issueIds.map((issueId) => ({
|
|
companyId: input.companyId,
|
|
userId: input.userId,
|
|
issueId,
|
|
kind: input.kind,
|
|
lastInteractedAt: interactedAt,
|
|
})))
|
|
.onConflictDoUpdate({
|
|
target: [issueUserRecency.userId, issueUserRecency.companyId, issueUserRecency.issueId],
|
|
set: { kind: input.kind, lastInteractedAt: interactedAt },
|
|
});
|
|
}
|
|
|
|
export async function recordIssueUserRecencyFromActivity(db: Db, input: LogActivityInput) {
|
|
const kind = issueRecencyKindForActivity(input);
|
|
if (!kind) return;
|
|
|
|
const issueIds = input.entityType === "approval"
|
|
? await db
|
|
.select({ issueId: issueApprovals.issueId })
|
|
.from(issueApprovals)
|
|
.where(and(eq(issueApprovals.companyId, input.companyId), eq(issueApprovals.approvalId, input.entityId)))
|
|
.then((rows) => rows.map((row) => row.issueId))
|
|
: [input.entityId];
|
|
|
|
await recordIssueUserRecency(db, {
|
|
companyId: input.companyId,
|
|
userId: input.actorId,
|
|
issueIds,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
export function issueUserRecencyService(db: Db) {
|
|
return {
|
|
listRecentIssues: async (
|
|
companyId: string,
|
|
userId: string,
|
|
requestedLimit = RECENT_ISSUES_MAX_LIMIT,
|
|
now = new Date(),
|
|
): Promise<RecentIssue[]> => {
|
|
const limit = Math.min(RECENT_ISSUES_MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
|
|
const cutoff = new Date(now.getTime() - RECENT_ISSUES_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
return db
|
|
.select({
|
|
id: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: sql<IssueStatus>`${issues.status}`,
|
|
lastInteractedAt: issueUserRecency.lastInteractedAt,
|
|
kind: issueUserRecency.kind,
|
|
hasActiveRun: sql<boolean>`exists (
|
|
select 1 from ${heartbeatRuns} live_run
|
|
where live_run.company_id = ${companyId}
|
|
and live_run.status in ('queued', 'running')
|
|
and (
|
|
live_run.id = ${issues.executionRunId}
|
|
or coalesce(live_run.context_snapshot ->> 'issueId', live_run.context_snapshot ->> 'taskId') = ${issues.id}::text
|
|
)
|
|
)`,
|
|
needsAttention: sql<boolean>`(
|
|
exists (
|
|
select 1 from ${issueThreadInteractions} pending_interaction
|
|
where pending_interaction.company_id = ${companyId}
|
|
and pending_interaction.issue_id = ${issues.id}
|
|
and pending_interaction.status = 'pending'
|
|
and not (
|
|
pending_interaction.effective_resolver_policy = 'not_creator'
|
|
and pending_interaction.created_by_user_id = ${userId}
|
|
)
|
|
)
|
|
or exists (
|
|
select 1 from ${issueApprovals} pending_issue_approval
|
|
inner join ${approvals} pending_approval on pending_approval.id = pending_issue_approval.approval_id
|
|
where pending_issue_approval.company_id = ${companyId}
|
|
and pending_issue_approval.issue_id = ${issues.id}
|
|
and pending_approval.company_id = ${companyId}
|
|
and pending_approval.status in ('pending', 'revision_requested')
|
|
)
|
|
)`,
|
|
})
|
|
.from(issueUserRecency)
|
|
.innerJoin(issues, and(
|
|
eq(issues.id, issueUserRecency.issueId),
|
|
eq(issues.companyId, issueUserRecency.companyId),
|
|
))
|
|
.where(and(
|
|
eq(issueUserRecency.companyId, companyId),
|
|
eq(issueUserRecency.userId, userId),
|
|
gte(issueUserRecency.lastInteractedAt, cutoff),
|
|
isNull(issues.hiddenAt),
|
|
))
|
|
.orderBy(desc(issueUserRecency.lastInteractedAt), desc(issueUserRecency.issueId))
|
|
.limit(limit);
|
|
},
|
|
};
|
|
}
|