mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat: add per-user recent issue history
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1ba7b2cc71
commit
c506d8c1b7
24 files changed
+41918
No files matched your search
@@ -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");
|
||||
+41403
File diff suppressed because it is too large.
Load diff
+7
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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')`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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.",
|
||||
|
||||
@@ -988,6 +988,8 @@ export type {
|
||||
Issue,
|
||||
IssueChangeReceiptEntry,
|
||||
IssueChanges,
|
||||
IssueUserRecencyKind,
|
||||
RecentIssue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerDiagnosticFlag,
|
||||
IssueBlockerDiagnosticIssueSummary,
|
||||
|
||||
@@ -614,6 +614,8 @@ export type {
|
||||
Issue,
|
||||
IssueChangeReceiptEntry,
|
||||
IssueChanges,
|
||||
IssueUserRecencyKind,
|
||||
RecentIssue,
|
||||
IssueWorkMode,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerDiagnosticFlag,
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface InstanceExperimentalSettings {
|
||||
enableSummaries: boolean;
|
||||
enableStatusCards: boolean;
|
||||
enableDecisions: boolean;
|
||||
enableRecentTasksSidebar: boolean;
|
||||
enableGoalsSidebarLink: boolean;
|
||||
enableServerInfoDebugView: boolean;
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user