feat: add per-user recent issue history

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-27 15:31:06 +00:00
1 parent 1ba7b2cc71
commit c506d8c1b7
24 files changed
+41918

No files matched your search

+4
View File
@@ -178,6 +178,10 @@ These rows are company-scoped and user-scoped. A missing row means the user is j
Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes.
## Recent issue interactions
`issue_user_recency` stores the latest qualifying issue interaction for each `(user_id, company_id, issue_id)` tuple. User-authored issue creation, comments, interaction-card answers, approval decisions, selected field edits, and issue-document updates refresh the row. Agent, system, inbound, and view-only activity does not. The recent-issues API joins the current issue at read time and only returns rows from the last 30 days, so deleted or hidden issues do not leave stale sidebar snapshots.
## Decision training snapshot retention
`decision_training_examples` stores a point-in-time copy of an issue, its comments, relevant runs, and the selected decision. Each row carries the `scrub_deleted_comments_v1` retention policy marker, and JSONL exports include that marker alongside the snapshot.
@@ -0,0 +1,14 @@
CREATE TABLE "issue_user_recency" (
"company_id" uuid NOT NULL,
"user_id" text NOT NULL,
"issue_id" uuid NOT NULL,
"last_interacted_at" timestamp with time zone DEFAULT now() NOT NULL,
"kind" text NOT NULL,
CONSTRAINT "issue_user_recency_user_company_issue_pk" PRIMARY KEY("user_id","company_id","issue_id"),
CONSTRAINT "issue_user_recency_kind_check" CHECK ("issue_user_recency"."kind" in ('created', 'commented', 'interaction', 'approval', 'edited', 'document'))
);
--> statement-breakpoint
ALTER TABLE "issue_user_recency" ADD CONSTRAINT "issue_user_recency_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_user_recency" ADD CONSTRAINT "issue_user_recency_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_user_recency_company_user_recent_idx" ON "issue_user_recency" USING btree ("company_id","user_id","last_interacted_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "issue_user_recency_company_issue_idx" ON "issue_user_recency" USING btree ("company_id","issue_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1583,6 +1583,13 @@
"when": 1787668790923,
"tag": "0227_modern_pandemic",
"breakpoints": true
},
{
"idx": 228,
"version": "7",
"when": 1787844474740,
"tag": "0228_early_wendell_rand",
"breakpoints": true
}
]
}
+1
View File
@@ -84,6 +84,7 @@ export { issueTreeHolds } from "./issue_tree_holds.js";
export { issueTreeHoldMembers } from "./issue_tree_hold_members.js";
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { issueUserRecency } from "./issue_user_recency.js";
export { userInboxAgentPolicies } from "./user_inbox_agent_policies.js";
export { inboxDismissals } from "./inbox_dismissals.js";
export { feedbackVotes } from "./feedback_votes.js";
@@ -0,0 +1,32 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
import type { IssueUserRecencyKind } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const issueUserRecency = pgTable(
"issue_user_recency",
{
companyId: uuid("company_id").notNull().references(() => companies.id),
userId: text("user_id").notNull(),
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
lastInteractedAt: timestamp("last_interacted_at", { withTimezone: true }).notNull().defaultNow(),
kind: text("kind").$type<IssueUserRecencyKind>().notNull(),
},
(table) => ({
pk: primaryKey({
columns: [table.userId, table.companyId, table.issueId],
name: "issue_user_recency_user_company_issue_pk",
}),
companyUserRecentIdx: index("issue_user_recency_company_user_recent_idx").on(
table.companyId,
table.userId,
table.lastInteractedAt.desc(),
),
companyIssueIdx: index("issue_user_recency_company_issue_idx").on(table.companyId, table.issueId),
kindCheck: check(
"issue_user_recency_kind_check",
sql`${table.kind} in ('created', 'commented', 'interaction', 'approval', 'edited', 'document')`,
),
}),
);
+7
View File
@@ -198,6 +198,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableRecentTasksSidebar: {
title: "Recent Tasks Sidebar",
description: "Show the current user's recently changed tasks in the main sidebar.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableGoalsSidebarLink: {
title: "Goals Sidebar Link",
description: "Restore the Goals item in the main sidebar while the goals surface is being evaluated.",
+2
View File
@@ -988,6 +988,8 @@ export type {
Issue,
IssueChangeReceiptEntry,
IssueChanges,
IssueUserRecencyKind,
RecentIssue,
IssueAssigneeAdapterOverrides,
IssueBlockerDiagnosticFlag,
IssueBlockerDiagnosticIssueSummary,
+2
View File
@@ -614,6 +614,8 @@ export type {
Issue,
IssueChangeReceiptEntry,
IssueChanges,
IssueUserRecencyKind,
RecentIssue,
IssueWorkMode,
IssueAssigneeAdapterOverrides,
IssueBlockerDiagnosticFlag,
+1
View File
@@ -73,6 +73,7 @@ export interface InstanceExperimentalSettings {
enableSummaries: boolean;
enableStatusCards: boolean;
enableDecisions: boolean;
enableRecentTasksSidebar: boolean;
enableGoalsSidebarLink: boolean;
enableServerInfoDebugView: boolean;
/**
+19
View File
@@ -784,6 +784,25 @@ export interface IssueChangeReceiptEntry {
export type IssueChanges = Record<string, IssueChangeReceiptEntry>;
export type IssueUserRecencyKind =
| "created"
| "commented"
| "interaction"
| "approval"
| "edited"
| "document";
export interface RecentIssue {
id: string;
identifier: string | null;
title: string;
status: IssueStatus;
lastInteractedAt: Date | string;
kind: IssueUserRecencyKind;
hasActiveRun: boolean;
needsAttention: boolean;
}
export interface Issue {
id: string;
companyId: string;
@@ -63,6 +63,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableSummaries: z.boolean().default(false),
enableStatusCards: z.boolean().default(false),
enableDecisions: z.boolean().default(false),
enableRecentTasksSidebar: z.boolean().default(false),
enableGoalsSidebarLink: z.boolean().default(false),
enableServerInfoDebugView: z.boolean().default(false),
enableSimplifiedEnglishInteractions: z.boolean().default(false),
@@ -46,6 +46,7 @@ describe("instance settings service", () => {
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,
enableRecentTasksSidebar: false,
enableGoalsSidebarLink: true,
enableServerInfoDebugView: true,
enableSimplifiedEnglishInteractions: false,
@@ -0,0 +1,211 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { asc, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
companies,
createDb,
heartbeatRuns,
issueApprovals,
issueThreadInteractions,
issueUserRecency,
issues,
type Db,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { logActivity } from "../services/activity-log.js";
import { errorHandler } from "../middleware/index.js";
import { recentIssueRoutes } from "../routes/recent-issues.js";
import {
issueRecencyKindForActivity,
issueUserRecencyService,
recordIssueUserRecency,
} from "../services/issue-user-recency.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describe("issueRecencyKindForActivity", () => {
it.each([
["issue.created", "issue", {}, "created"],
["issue.comment_added", "issue", {}, "commented"],
["issue.thread_interaction_accepted", "issue", {}, "interaction"],
["issue.thread_interaction_rejected", "issue", {}, "interaction"],
["issue.thread_interaction_answered", "issue", {}, "interaction"],
["issue.thread_interaction_item_verdicts_submitted", "issue", {}, "interaction"],
["issue.updated", "issue", { changes: { status: { from: "todo", to: "done" } } }, "edited"],
["issue.document_updated", "issue", {}, "document"],
["approval.approved", "approval", {}, "approval"],
])("maps qualifying user activity %s", (action, entityType, details, expected) => {
expect(issueRecencyKindForActivity({ actorType: "user", action, entityType, details })).toBe(expected);
});
it("excludes agent, inbound/system, view, and unrelated field activity", () => {
expect(issueRecencyKindForActivity({ actorType: "agent", action: "issue.comment_added", entityType: "issue" })).toBeNull();
expect(issueRecencyKindForActivity({ actorType: "system", action: "issue.comment_added", entityType: "issue" })).toBeNull();
expect(issueRecencyKindForActivity({ actorType: "user", action: "issue.read_marked", entityType: "issue" })).toBeNull();
expect(issueRecencyKindForActivity({
actorType: "user",
action: "issue.updated",
entityType: "issue",
details: { changes: { description: { from: "a", to: "b" } } },
})).toBeNull();
});
});
describeEmbeddedPostgres("issue user recency persistence", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: Db;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-user-recency-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueUserRecency);
await db.delete(issueApprovals);
await db.delete(issueThreadInteractions);
await db.delete(approvals);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => tempDb?.cleanup());
async function seedCompany(prefix: string) {
const id = randomUUID();
await db.insert(companies).values({ id, name: prefix, issuePrefix: prefix });
return id;
}
async function seedIssue(companyId: string, title: string, identifier: string) {
const id = randomUUID();
await db.insert(issues).values({ id, companyId, title, identifier, status: "todo", priority: "medium" });
return id;
}
it("upserts every qualifying user write while excluding agent and inbound activity", async () => {
const companyId = await seedCompany("RQA");
const userId = "user-a";
const actions = [
["issue.created", "created", "issue", {}],
["issue.comment_added", "commented", "issue", {}],
["issue.thread_interaction_answered", "interaction", "issue", {}],
["issue.updated", "edited", "issue", { changes: { title: { from: "a", to: "b" } } }],
["issue.document_updated", "document", "issue", {}],
] as const;
const expected = new Map<string, string>();
for (let index = 0; index < actions.length; index += 1) {
const [action, kind, entityType, details] = actions[index]!;
const issueId = await seedIssue(companyId, kind, `RQA-${index + 1}`);
expected.set(issueId, kind);
await logActivity(db, { companyId, actorType: "user", actorId: userId, action, entityType, entityId: issueId, details });
}
const approvalIssueId = await seedIssue(companyId, "approval", "RQA-6");
const [approval] = await db.insert(approvals).values({
companyId,
type: "request_board_approval",
status: "pending",
payload: {},
}).returning();
await db.insert(issueApprovals).values({ companyId, issueId: approvalIssueId, approvalId: approval!.id });
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "approval.approved",
entityType: "approval",
entityId: approval!.id,
});
expected.set(approvalIssueId, "approval");
const excludedIssueId = await seedIssue(companyId, "excluded", "RQA-7");
await logActivity(db, { companyId, actorType: "agent", actorId: randomUUID(), action: "issue.comment_added", entityType: "issue", entityId: excludedIssueId });
await logActivity(db, { companyId, actorType: "system", actorId: "inbound", action: "issue.updated", entityType: "issue", entityId: excludedIssueId, details: { status: "done" } });
await logActivity(db, { companyId, actorType: "user", actorId: userId, action: "issue.read_marked", entityType: "issue", entityId: excludedIssueId });
const rows = await db.select().from(issueUserRecency).orderBy(asc(issueUserRecency.issueId));
expect(rows).toHaveLength(expected.size);
expect(new Map(rows.map((row) => [row.issueId, row.kind]))).toEqual(expected);
});
it("enforces company scope, the 30-day window, hard cap, ordering, and decorations", async () => {
const companyId = await seedCompany("RQB");
const otherCompanyId = await seedCompany("RQC");
const userId = "user-a";
const now = new Date("2026-08-27T12:00:00.000Z");
const issueIds: string[] = [];
for (let index = 0; index < 27; index += 1) {
const issueId = await seedIssue(companyId, `Recent ${index}`, `RQB-${index + 1}`);
issueIds.push(issueId);
await recordIssueUserRecency(db, {
companyId,
userId,
issueIds: [issueId],
kind: "edited",
interactedAt: new Date(now.getTime() - index * 60_000),
});
}
const oldIssueId = await seedIssue(companyId, "Old", "RQB-28");
await recordIssueUserRecency(db, { companyId, userId, issueIds: [oldIssueId], kind: "commented", interactedAt: new Date("2026-07-01T00:00:00.000Z") });
const otherIssueId = await seedIssue(otherCompanyId, "Other company", "RQC-1");
await recordIssueUserRecency(db, { companyId: otherCompanyId, userId, issueIds: [otherIssueId], kind: "edited", interactedAt: now });
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId, name: "Runner", role: "engineer", adapterType: "process", adapterConfig: {} });
const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "running", contextSnapshot: { issueId: issueIds[0] } }).returning();
await db.update(issues).set({ executionRunId: run!.id }).where(eq(issues.id, issueIds[0]!));
await db.insert(issueThreadInteractions).values({
companyId,
issueId: issueIds[0]!,
kind: "request_confirmation",
status: "pending",
payload: { version: 1, prompt: "Confirm" },
createdByAgentId: agentId,
});
const rows = await issueUserRecencyService(db).listRecentIssues(companyId, userId, 100, now);
expect(rows).toHaveLength(25);
expect(rows.map((row) => row.id)).toEqual(issueIds.slice(0, 25));
expect(rows.some((row) => row.id === oldIssueId || row.id === otherIssueId)).toBe(false);
expect(rows[0]).toMatchObject({ hasActiveRun: true, needsAttention: true });
});
it("serves the current board user's company-scoped list and validates limit", async () => {
const companyId = await seedCompany("RQD");
const issueId = await seedIssue(companyId, "Current", "RQD-1");
await recordIssueUserRecency(db, { companyId, userId: "user-a", issueIds: [issueId], kind: "commented" });
const app = express();
app.use((req, _res, next) => {
req.actor = {
type: "board",
userId: "user-a",
source: "session",
isInstanceAdmin: false,
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "viewer", status: "active" }],
};
next();
});
app.use("/api", recentIssueRoutes(db));
app.use(errorHandler);
const ok = await request(app).get(`/api/companies/${companyId}/users/me/recent-issues?limit=25`);
expect(ok.status).toBe(200);
expect(ok.body).toEqual([expect.objectContaining({ id: issueId, kind: "commented" })]);
expect((await request(app).get(`/api/companies/${companyId}/users/me/recent-issues?limit=0`)).status).toBe(400);
expect((await request(app).get(`/api/companies/${randomUUID()}/users/me/recent-issues`)).status).toBe(403);
});
});
@@ -49,6 +49,7 @@ const apiPrefixes: Record<string, string> = {
"plugin-ui-static.ts": "/api",
"plugins.ts": "/api",
"projects.ts": "/api",
"recent-issues.ts": "/api",
"resource-memberships.ts": "/api",
"routines.ts": "/api",
"secrets.ts": "/api",
+2
View File
@@ -68,6 +68,7 @@ import { userProfileRoutes } from "./routes/user-profiles.js";
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
import { resourceMembershipRoutes } from "./routes/resource-memberships.js";
import { recentIssueRoutes } from "./routes/recent-issues.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { instanceSettingsService } from "./services/instance-settings.js";
@@ -539,6 +540,7 @@ export async function createApp(
api.use(sidebarBadgeRoutes(db));
api.use(sidebarPreferenceRoutes(db));
api.use(resourceMembershipRoutes(db));
api.use(recentIssueRoutes(db));
api.use(inboxDismissalRoutes(db));
api.use(instanceSettingsRoutes(db));
if (opts.databaseBackupService) {
+1
View File
@@ -34,6 +34,7 @@ export { decisionQueueRoutes } from "./decision-queues.js";
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
export { resourceMembershipRoutes } from "./resource-memberships.js";
export { recentIssueRoutes } from "./recent-issues.js";
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
export { llmRoutes } from "./llms.js";
export { accessRoutes } from "./access.js";
+2
View File
@@ -861,6 +861,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
"DELETE /api/board-api-keys/{keyId}",
"POST /api/bootstrap/claim",
"GET /api/companies/{companyId}/resource-memberships/me",
"GET /api/companies/{companyId}/users/me/recent-issues",
"PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}",
"PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}",
"PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}",
@@ -6750,6 +6751,7 @@ registerCurrentRoute({
for (const route of [
["get", "/api/companies/{companyId}/resource-memberships/me", "List current user's resource memberships"],
["get", "/api/companies/{companyId}/users/me/recent-issues", "List current user's recent issues"],
["put", "/api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "Join or leave an agent resource"],
["put", "/api/companies/{companyId}/resource-memberships/me/projects/{projectId}", "Join or leave a project resource"],
] as const) {
+33
View File
@@ -0,0 +1,33 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { assertCompanyAccess } from "./authz.js";
import { issueUserRecencyService, RECENT_ISSUES_MAX_LIMIT } from "../services/issue-user-recency.js";
export function recentIssueRoutes(db: Db) {
const router = Router();
const service = issueUserRecencyService(db);
router.get("/companies/:companyId/users/me/recent-issues", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board" || !req.actor.userId) {
res.status(403).json({ error: "Board user access required" });
return;
}
const rawLimit = req.query.limit;
if (rawLimit !== undefined && (typeof rawLimit !== "string" || !/^\d+$/.test(rawLimit))) {
res.status(400).json({ error: `limit must be a positive integer up to ${RECENT_ISSUES_MAX_LIMIT}` });
return;
}
const parsedLimit = rawLimit === undefined ? RECENT_ISSUES_MAX_LIMIT : Number.parseInt(rawLimit, 10);
if (parsedLimit < 1) {
res.status(400).json({ error: `limit must be a positive integer up to ${RECENT_ISSUES_MAX_LIMIT}` });
return;
}
res.json(await service.listRecentIssues(companyId, req.actor.userId, parsedLimit));
});
return router;
}
+3
View File
@@ -10,6 +10,7 @@ import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
import { recordIssueUserRecencyFromActivity } from "./issue-user-recency.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly<Record<string, PluginEventType>> = {
@@ -173,6 +174,8 @@ export async function persistActivity(db: Db, input: LogActivityInput) {
details: redactedDetails,
}).returning({ id: activityLog.id });
await recordIssueUserRecencyFromActivity(db, input);
const payload = {
actorType: input.actorType,
actorId: input.actorId,
+8
View File
@@ -184,6 +184,14 @@ export {
type ActivityPublication,
type LogActivityInput,
} from "./activity-log.js";
export {
issueRecencyKindForActivity,
issueUserRecencyService,
recordIssueUserRecency,
recordIssueUserRecencyFromActivity,
RECENT_ISSUES_MAX_LIMIT,
RECENT_ISSUES_WINDOW_DAYS,
} from "./issue-user-recency.js";
export { summarySlotService, SUMMARIZER_BUILT_IN_KEY } from "./summary-slots.js";
export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js";
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
+2
View File
@@ -238,6 +238,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableSummaries: parsed.data.enableSummaries ?? false,
enableStatusCards: parsed.data.enableStatusCards ?? false,
enableDecisions: parsed.data.enableDecisions ?? false,
enableRecentTasksSidebar: parsed.data.enableRecentTasksSidebar ?? false,
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
@@ -277,6 +278,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,
enableRecentTasksSidebar: false,
enableGoalsSidebarLink: false,
enableServerInfoDebugView: false,
enableSimplifiedEnglishInteractions: false,
+160
View File
@@ -0,0 +1,160 @@
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);
},
};
}
@@ -89,6 +89,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableSummaries: false,
enableStatusCards: false,
enableDecisions: false,
enableRecentTasksSidebar: false,
enableGoalsSidebarLink: false,
enableTaskWatchdogs: false,
enableServerInfoDebugView: false,