Enrich attention feed and add snooze dismissals
Co-Authored-By: Paperclip <noreply@paperclip.ing>
No files matched your search
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "kind" text;--> statement-breakpoint
|
||||
UPDATE "inbox_dismissals" SET "kind" = 'dismiss' WHERE "kind" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET DEFAULT 'dismiss';--> statement-breakpoint
|
||||
ALTER TABLE "inbox_dismissals" ALTER COLUMN "kind" SET NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "inbox_dismissals" ADD COLUMN IF NOT EXISTS "snoozed_until" timestamp with time zone;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_check" CHECK ("kind" IN ('dismiss', 'snooze'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_kind_snooze_until_check" CHECK (("kind" = 'dismiss' AND "snoozed_until" IS NULL) OR ("kind" = 'snooze' AND "snoozed_until" IS NOT NULL));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -1002,6 +1002,13 @@
|
||||
"when": 1783520000000,
|
||||
"tag": "0144_case_document_annotations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 145,
|
||||
"version": "7",
|
||||
"when": 1783641600000,
|
||||
"tag": "0145_inbox_dismissal_snooze_kind",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,9 @@ export const inboxDismissals = pgTable(
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
userId: text("user_id").notNull(),
|
||||
itemKey: text("item_key").notNull(),
|
||||
kind: text("kind").$type<"dismiss" | "snooze">().notNull().default("dismiss"),
|
||||
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
snoozedUntil: timestamp("snoozed_until", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -51,12 +51,17 @@ export {
|
||||
} from "./responsible-user-denial.js";
|
||||
export type {
|
||||
AttentionDecisionVerb,
|
||||
AttentionDetailImage,
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionItemDetail,
|
||||
AttentionItemDismissal,
|
||||
AttentionProjectRef,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
AttentionSubjectKind,
|
||||
AttentionWorkspaceRef,
|
||||
} from "./types/attention.js";
|
||||
|
||||
export type {
|
||||
@@ -874,6 +879,7 @@ export type {
|
||||
SidebarBadges,
|
||||
SidebarOrderPreference,
|
||||
InboxDismissal,
|
||||
InboxDismissalKind,
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
CompanyMembersResponse,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { InboxDismissalKind } from "./inbox-dismissal.js";
|
||||
|
||||
export type AttentionSourceKind =
|
||||
| "approval"
|
||||
| "issue_thread_interaction"
|
||||
@@ -39,6 +41,107 @@ export interface AttentionDecisionVerb {
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface AttentionProjectRef {
|
||||
id: string;
|
||||
name: string;
|
||||
urlKey: string;
|
||||
}
|
||||
|
||||
export interface AttentionWorkspaceRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AttentionDetailImage {
|
||||
assetId: string;
|
||||
alt?: string | null;
|
||||
}
|
||||
|
||||
export interface AttentionItemDismissal {
|
||||
kind: InboxDismissalKind;
|
||||
dismissedAt: string;
|
||||
snoozedUntil: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type AttentionItemDetail =
|
||||
| {
|
||||
kind: "approval";
|
||||
approvalType: string;
|
||||
summaryExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "plan_approval";
|
||||
issueTitle: string | null;
|
||||
planTitle: string | null;
|
||||
summaryExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "confirmation";
|
||||
promptExcerpt: string | null;
|
||||
isPlanTarget: false;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "questions";
|
||||
questionCount: number;
|
||||
firstQuestionText: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "suggested_tasks";
|
||||
taskCount: number;
|
||||
firstTaskTitle: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "checkbox_confirmation";
|
||||
optionCount: number;
|
||||
promptExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "item_verdicts";
|
||||
itemCount: number;
|
||||
promptExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "failed_run";
|
||||
agentName: string | null;
|
||||
failureReasonExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "blocker";
|
||||
blockingIssue: {
|
||||
id: string | null;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
} | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "budget";
|
||||
observedPercent: number;
|
||||
amountObserved: number;
|
||||
amountLimit: number;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "agent_error";
|
||||
agentName: string | null;
|
||||
failureReasonExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
}
|
||||
| {
|
||||
kind: "generic";
|
||||
summaryExcerpt: string | null;
|
||||
images: AttentionDetailImage[];
|
||||
};
|
||||
|
||||
export interface AttentionItem {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -51,12 +154,16 @@ export interface AttentionItem {
|
||||
exitRule: string;
|
||||
dedupKey: string;
|
||||
dismissalKey: string;
|
||||
dismissal: AttentionItemDismissal | null;
|
||||
severity: AttentionSeverity;
|
||||
rank: number;
|
||||
activityAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
relatedIssue: AttentionSubject | null;
|
||||
project: AttentionProjectRef | null;
|
||||
workspace: AttentionWorkspaceRef | null;
|
||||
detail: AttentionItemDetail | null;
|
||||
}
|
||||
|
||||
export interface AttentionFeed {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export type InboxDismissalKind = "dismiss" | "snooze";
|
||||
|
||||
export interface InboxDismissal {
|
||||
id: string;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
itemKey: string;
|
||||
kind: InboxDismissalKind;
|
||||
dismissedAt: Date;
|
||||
snoozedUntil: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
export type { Company } from "./company.js";
|
||||
export type {
|
||||
AttentionDecisionVerb,
|
||||
AttentionDetailImage,
|
||||
AttentionFeed,
|
||||
AttentionItem,
|
||||
AttentionItemDetail,
|
||||
AttentionItemDismissal,
|
||||
AttentionProjectRef,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
AttentionSubjectKind,
|
||||
AttentionWorkspaceRef,
|
||||
} from "./attention.js";
|
||||
export type {
|
||||
Environment,
|
||||
@@ -573,7 +578,7 @@ export type {
|
||||
UpdateResourceMembership,
|
||||
} from "./resource-memberships.js";
|
||||
export { RESOURCE_MEMBERSHIP_STATES } from "./resource-memberships.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
export type { InboxDismissal, InboxDismissalKind } from "./inbox-dismissal.js";
|
||||
export type {
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
|
||||
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 436 KiB |
@@ -7,20 +7,26 @@ import {
|
||||
activityLog,
|
||||
agents,
|
||||
approvals,
|
||||
assets,
|
||||
budgetIncidents,
|
||||
budgetPolicies,
|
||||
companies,
|
||||
createDb,
|
||||
documents,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
issueApprovals,
|
||||
issueAttachments,
|
||||
issueDocuments,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
joinRequests,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -52,6 +58,8 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueApprovals);
|
||||
await db.delete(issueAttachments);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(budgetIncidents);
|
||||
@@ -63,6 +71,10 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(approvals);
|
||||
await db.delete(issues);
|
||||
await db.delete(assets);
|
||||
await db.delete(documents);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
@@ -136,6 +148,8 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
originKind?: string;
|
||||
originId?: string | null;
|
||||
originFingerprint?: string;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
updatedAt?: Date;
|
||||
createdAt?: Date;
|
||||
@@ -149,6 +163,8 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
status: input.status,
|
||||
priority: input.priority ?? "medium",
|
||||
parentId: input.parentId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
projectWorkspaceId: input.projectWorkspaceId ?? null,
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: input.assigneeUserId ?? null,
|
||||
originKind: input.originKind ?? "manual",
|
||||
@@ -564,9 +580,208 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
expect(feed.items.some((item) =>
|
||||
item.sourceKind === "failed_run" && item.subject.metadata?.errorCode === "provider_quota"
|
||||
)).toBe(false);
|
||||
expect(feed.items.some((item) =>
|
||||
item.sourceKind === "budget_alert" && item.subject.metadata?.observedPercent === 84
|
||||
)).toBe(false);
|
||||
expect(feed.items.find((item) => item.sourceKind === "approval")?.detail).toMatchObject({
|
||||
kind: "approval",
|
||||
approvalType: "hire_agent",
|
||||
summaryExcerpt: "Hire Designer",
|
||||
});
|
||||
expect(feed.items.find((item) => item.sourceKind === "issue_thread_interaction")?.detail).toMatchObject({
|
||||
kind: "questions",
|
||||
questionCount: 0,
|
||||
});
|
||||
expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.detail).toMatchObject({
|
||||
kind: "blocker",
|
||||
blockingIssue: { identifier: "ATN-5", title: "Stalled review blocker" },
|
||||
});
|
||||
expect(feed.items.find((item) => item.sourceKind === "failed_run")?.detail).toMatchObject({
|
||||
kind: "failed_run",
|
||||
agentName: "Worker",
|
||||
failureReasonExcerpt: "adapter failed",
|
||||
});
|
||||
expect(feed.items.find((item) =>
|
||||
item.sourceKind === "budget_alert" && item.detail?.kind === "budget" && item.detail.observedPercent === 100
|
||||
)).toBeTruthy();
|
||||
expect(feed.items.find((item) => item.sourceKind === "agent_error_alert")?.detail).toMatchObject({
|
||||
kind: "agent_error",
|
||||
agentName: "Broken Agent",
|
||||
failureReasonExcerpt: "adapter config missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches interaction details with project, workspace, plan metadata, and images", async () => {
|
||||
const { companyId, workerId } = await seedCompany("ATE");
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const planDocumentId = randomUUID();
|
||||
const planRevisionId = randomUUID();
|
||||
const imageAssetIds = [randomUUID(), randomUUID(), randomUUID(), randomUUID()];
|
||||
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Attention Project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Preview workspace",
|
||||
sourceType: "local_path",
|
||||
isPrimary: true,
|
||||
});
|
||||
await insertIssue({
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "ATE-1",
|
||||
title: "Approve launch plan",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: workerId,
|
||||
projectId,
|
||||
projectWorkspaceId: workspaceId,
|
||||
updatedAt: new Date("2026-07-09T12:00:00.000Z"),
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: planDocumentId,
|
||||
companyId,
|
||||
title: "Launch Plan",
|
||||
format: "markdown",
|
||||
latestBody: "# Summary\n\nThis plan explains the launch checklist, rollout owner, QA gates, and risk controls for the homepage release.",
|
||||
latestRevisionId: planRevisionId,
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId: planDocumentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(assets).values([
|
||||
{ id: imageAssetIds[0], companyId, provider: "local_disk", objectKey: "img-1", contentType: "image/png", byteSize: 10, sha256: "a".repeat(64), originalFilename: "one.png" },
|
||||
{ id: imageAssetIds[1], companyId, provider: "local_disk", objectKey: "img-2", contentType: "image/jpeg", byteSize: 10, sha256: "b".repeat(64), originalFilename: "two.jpg" },
|
||||
{ id: imageAssetIds[2], companyId, provider: "local_disk", objectKey: "img-3", contentType: "image/gif", byteSize: 10, sha256: "c".repeat(64), originalFilename: "three.gif" },
|
||||
{ id: imageAssetIds[3], companyId, provider: "local_disk", objectKey: "img-4", contentType: "image/png", byteSize: 10, sha256: "d".repeat(64), originalFilename: "four.png" },
|
||||
]);
|
||||
await db.insert(issueAttachments).values(imageAssetIds.map((assetId, index) => ({
|
||||
companyId,
|
||||
issueId,
|
||||
assetId,
|
||||
createdAt: new Date(`2026-07-09T12:0${index}:30.000Z`),
|
||||
updatedAt: new Date(`2026-07-09T12:0${index}:30.000Z`),
|
||||
})));
|
||||
|
||||
const planInteractionId = randomUUID();
|
||||
const questionsInteractionId = randomUUID();
|
||||
const tasksInteractionId = randomUUID();
|
||||
const checkboxInteractionId = randomUUID();
|
||||
const verdictInteractionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values([
|
||||
{
|
||||
id: planInteractionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Approve the plan",
|
||||
payload: { version: 1, prompt: "Approve plan?", target: { type: "issue_document", issueId, key: "plan", revisionId: planRevisionId } },
|
||||
createdAt: new Date("2026-07-09T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:01:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: questionsInteractionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Questions",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [
|
||||
{ id: "q1", prompt: "Which auth provider should we use?", selectionMode: "single", options: [] },
|
||||
{ id: "q2", prompt: "Should we add a fallback?", selectionMode: "single", options: [] },
|
||||
],
|
||||
},
|
||||
createdAt: new Date("2026-07-09T12:02:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:02:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: tasksInteractionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "suggest_tasks",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Tasks",
|
||||
payload: { version: 1, tasks: [{ clientKey: "t1", title: "Build API" }, { clientKey: "t2", title: "Wire UI" }] },
|
||||
createdAt: new Date("2026-07-09T12:03:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:03:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: checkboxInteractionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Checkbox",
|
||||
payload: { version: 1, prompt: "Select rollout regions", options: [{ id: "us", label: "US" }, { id: "eu", label: "EU" }] },
|
||||
createdAt: new Date("2026-07-09T12:04:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:04:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: verdictInteractionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_item_verdicts",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
title: "Verdicts",
|
||||
payload: { version: 1, prompt: "Approve these screenshots", items: [{ id: "one", label: "One" }, { id: "two", label: "Two" }] },
|
||||
createdAt: new Date("2026-07-09T12:05:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:05:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const feed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const interactionItems = feed.items.filter((item) => item.sourceKind === "issue_thread_interaction");
|
||||
const detailsByKind = new Map(interactionItems.map((item) => [item.detail?.kind, item]));
|
||||
|
||||
const planItem = detailsByKind.get("plan_approval");
|
||||
expect(planItem?.subject.title).toBe("Plan approval - Approve launch plan");
|
||||
expect(planItem?.subject.metadata).toMatchObject({ isPlanTarget: true, targetDocumentKey: "plan" });
|
||||
expect(planItem?.project).toMatchObject({ id: projectId, name: "Attention Project" });
|
||||
expect(planItem?.project?.urlKey).toEqual(expect.any(String));
|
||||
expect(planItem?.workspace).toEqual({ id: workspaceId, name: "Preview workspace" });
|
||||
expect(planItem?.detail).toMatchObject({
|
||||
kind: "plan_approval",
|
||||
issueTitle: "Approve launch plan",
|
||||
planTitle: "Launch Plan",
|
||||
summaryExcerpt: expect.stringContaining("launch checklist"),
|
||||
images: imageAssetIds.slice(0, 3).map((assetId) => ({ assetId, alt: expect.any(String) })),
|
||||
});
|
||||
expect(detailsByKind.get("questions")?.detail).toMatchObject({
|
||||
kind: "questions",
|
||||
questionCount: 2,
|
||||
firstQuestionText: "Which auth provider should we use?",
|
||||
});
|
||||
expect(detailsByKind.get("suggested_tasks")?.detail).toMatchObject({
|
||||
kind: "suggested_tasks",
|
||||
taskCount: 2,
|
||||
firstTaskTitle: "Build API",
|
||||
});
|
||||
expect(detailsByKind.get("checkbox_confirmation")?.detail).toMatchObject({
|
||||
kind: "checkbox_confirmation",
|
||||
optionCount: 2,
|
||||
promptExcerpt: "Select rollout regions",
|
||||
});
|
||||
expect(detailsByKind.get("item_verdicts")?.detail).toMatchObject({
|
||||
kind: "item_verdicts",
|
||||
itemCount: 2,
|
||||
promptExcerpt: "Approve these screenshots",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => {
|
||||
@@ -590,8 +805,10 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user" }))
|
||||
.resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true }))
|
||||
.resolves.toMatchObject({ totalCount: 2 });
|
||||
const includeDismissedFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true });
|
||||
expect(includeDismissedFeed.totalCount).toBe(2);
|
||||
expect(includeDismissedFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal)
|
||||
.toMatchObject({ kind: "dismiss", isActive: true, snoozedUntil: null });
|
||||
|
||||
await db
|
||||
.update(approvals)
|
||||
@@ -602,6 +819,44 @@ describeEmbeddedPostgres("attention service", () => {
|
||||
expect(feed.items.some((item) => item.dedupKey === `approval:${approvalId}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides snoozed attention rows until snoozedUntil passes, then returns them unconditionally", async () => {
|
||||
const { companyId } = await seedCompany("ATS");
|
||||
const approvalId = randomUUID();
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "hire_agent",
|
||||
status: "pending",
|
||||
payload: { title: "Hire Researcher" },
|
||||
createdAt: new Date("2026-07-09T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-09T12:00:00.000Z"),
|
||||
});
|
||||
await db.insert(inboxDismissals).values({
|
||||
companyId,
|
||||
userId: "board-user",
|
||||
itemKey: `attention:approval:${approvalId}`,
|
||||
kind: "snooze",
|
||||
dismissedAt: new Date("2099-01-01T00:00:00.000Z"),
|
||||
snoozedUntil: new Date("2099-01-02T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(attentionService(db).list(companyId, { userId: "board-user" }))
|
||||
.resolves.toMatchObject({ totalCount: 1 }); // agent_error_alert from seed
|
||||
const hiddenFeed = await attentionService(db).list(companyId, { userId: "board-user", includeDismissed: true });
|
||||
expect(hiddenFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`)?.dismissal)
|
||||
.toMatchObject({ kind: "snooze", isActive: true, snoozedUntil: "2099-01-02T00:00:00.000Z" });
|
||||
|
||||
await db
|
||||
.update(inboxDismissals)
|
||||
.set({ snoozedUntil: new Date("2020-01-01T00:00:00.000Z") })
|
||||
.where(eq(inboxDismissals.itemKey, `attention:approval:${approvalId}`));
|
||||
|
||||
const visibleFeed = await attentionService(db).list(companyId, { userId: "board-user" });
|
||||
const visibleApproval = visibleFeed.items.find((item) => item.dedupKey === `approval:${approvalId}`);
|
||||
expect(visibleApproval?.dismissal).toMatchObject({ kind: "snooze", isActive: false });
|
||||
expect(visibleApproval).toBeTruthy();
|
||||
});
|
||||
|
||||
it("serves the route for board users and rejects agent callers", async () => {
|
||||
const { companyId } = await seedCompany("ATR");
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
approvals,
|
||||
companies,
|
||||
@@ -14,6 +17,8 @@ import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { inboxDismissalRoutes } from "../routes/inbox-dismissals.js";
|
||||
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
|
||||
|
||||
@@ -43,6 +48,7 @@ describeEmbeddedPostgres("inbox dismissals", () => {
|
||||
await db.delete(inboxDismissals);
|
||||
await db.delete(joinRequests);
|
||||
await db.delete(invites);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(approvals);
|
||||
await db.delete(agents);
|
||||
@@ -73,9 +79,63 @@ describeEmbeddedPostgres("inbox dismissals", () => {
|
||||
|
||||
expect(dismissals).toHaveLength(1);
|
||||
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
|
||||
expect(dismissals[0]?.kind).toBe("dismiss");
|
||||
expect(dismissals[0]?.snoozedUntil).toBeNull();
|
||||
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
|
||||
});
|
||||
|
||||
it("snoozes and restores dismissal records through the route", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", inboxDismissalRoutes(db));
|
||||
app.use(errorHandler);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/companies/${companyId}/inbox-dismissals`)
|
||||
.send({ itemKey: "attention:approval:old", kind: "snooze", snoozedUntil: "2020-01-01T00:00:00.000Z" })
|
||||
.expect(400);
|
||||
|
||||
const snoozedUntil = "2099-01-01T00:00:00.000Z";
|
||||
const createRes = await request(app)
|
||||
.post(`/api/companies/${companyId}/inbox-dismissals`)
|
||||
.send({ itemKey: "attention:approval:approval-1", kind: "snooze", snoozedUntil })
|
||||
.expect(201);
|
||||
|
||||
expect(createRes.body).toMatchObject({
|
||||
companyId,
|
||||
userId,
|
||||
itemKey: "attention:approval:approval-1",
|
||||
kind: "snooze",
|
||||
snoozedUntil,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent("attention:approval:approval-1")}`)
|
||||
.expect(204);
|
||||
|
||||
await expect(dismissalsSvc.list(companyId, userId)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
|
||||
const companyId = randomUUID();
|
||||
const userId = "board-user";
|
||||
|
||||
@@ -1,14 +1,50 @@
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { z } from "zod";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { inboxDismissalService, logActivity } from "../services/index.js";
|
||||
|
||||
const ITEM_KEY_RE = /^(approval|join|run|attention):.+$/;
|
||||
|
||||
const inboxDismissalSchema = z.object({
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"),
|
||||
itemKey: z.string().trim().min(1).regex(ITEM_KEY_RE, "Unsupported inbox item key"),
|
||||
kind: z.enum(["dismiss", "snooze"]).default("dismiss"),
|
||||
snoozedUntil: z.string().trim().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.kind === "dismiss") {
|
||||
if (value.snoozedUntil != null) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Dismissals must not include snoozedUntil" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value.snoozedUntil) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "Snooze requires snoozedUntil" });
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date(value.snoozedUntil).getTime();
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be an ISO timestamp" });
|
||||
return;
|
||||
}
|
||||
if (timestamp <= Date.now()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["snoozedUntil"], message: "snoozedUntil must be in the future" });
|
||||
}
|
||||
});
|
||||
|
||||
function requireBoardUser(req: Request, res: Response) {
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return null;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return null;
|
||||
}
|
||||
return req.actor.userId;
|
||||
}
|
||||
|
||||
export function inboxDismissalRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = inboxDismissalService(db);
|
||||
@@ -16,15 +52,10 @@ export function inboxDismissalRoutes(db: Db) {
|
||||
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
const dismissals = await svc.list(companyId, req.actor.userId);
|
||||
const userId = requireBoardUser(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const dismissals = await svc.list(companyId, userId);
|
||||
res.json(dismissals);
|
||||
});
|
||||
|
||||
@@ -34,16 +65,12 @@ export function inboxDismissalRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return;
|
||||
}
|
||||
const userId = requireBoardUser(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
|
||||
const dismissal = req.body.kind === "snooze"
|
||||
? await svc.snooze(companyId, userId, req.body.itemKey, new Date(req.body.snoozedUntil))
|
||||
: await svc.dismiss(companyId, userId, req.body.itemKey, new Date());
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -51,13 +78,15 @@ export function inboxDismissalRoutes(db: Db) {
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "inbox.dismissed",
|
||||
action: dismissal.kind === "snooze" ? "inbox.snoozed" : "inbox.dismissed",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
userId: req.actor.userId,
|
||||
userId,
|
||||
itemKey: dismissal.itemKey,
|
||||
kind: dismissal.kind,
|
||||
dismissedAt: dismissal.dismissedAt,
|
||||
snoozedUntil: dismissal.snoozedUntil,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,5 +94,42 @@ export function inboxDismissalRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.delete("/companies/:companyId/inbox-dismissals/:itemKey", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUser(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const itemKey = req.params.itemKey as string;
|
||||
if (!ITEM_KEY_RE.test(itemKey)) {
|
||||
res.status(400).json({ error: "Unsupported inbox item key" });
|
||||
return;
|
||||
}
|
||||
|
||||
const restored = await svc.restore(companyId, userId, itemKey);
|
||||
if (restored) {
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "inbox.restored",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
userId,
|
||||
itemKey: restored.itemKey,
|
||||
kind: restored.kind,
|
||||
dismissedAt: restored.dismissedAt,
|
||||
snoozedUntil: restored.snoozedUntil,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -111,6 +111,7 @@ import {
|
||||
acceptIssueThreadInteractionSchema,
|
||||
rejectIssueThreadInteractionSchema,
|
||||
respondIssueThreadInteractionSchema,
|
||||
submitIssueThreadInteractionVerdictsSchema,
|
||||
// Auth / profile
|
||||
updateCurrentUserProfileSchema,
|
||||
// Company portability (legacy routes)
|
||||
@@ -2875,14 +2876,25 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/inbox-dismissals",
|
||||
tags: ["inbox"],
|
||||
summary: "Create an inbox dismissal",
|
||||
summary: "Create an inbox dismissal or snooze",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(z.object({
|
||||
itemKey: z.string().trim().min(1).regex(/^(approval|join|run|attention):.+$/, "Unsupported inbox item key"),
|
||||
kind: z.enum(["dismiss", "snooze"]).optional(),
|
||||
snoozedUntil: z.string().datetime().optional(),
|
||||
})),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/api/companies/{companyId}/inbox-dismissals/{itemKey}",
|
||||
tags: ["inbox"],
|
||||
summary: "Restore an inbox dismissal or snooze",
|
||||
request: { params: z.object({ companyId: z.string(), itemKey: z.string() }) },
|
||||
responses: { 204: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
});
|
||||
|
||||
// ─── Instance settings ────────────────────────────────────────────────────────
|
||||
@@ -3442,6 +3454,18 @@ registry.registerPath({
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/interactions/{interactionId}/verdicts",
|
||||
tags: ["issues"],
|
||||
summary: "Submit item verdicts on an issue thread interaction",
|
||||
request: {
|
||||
params: z.object({ id: z.string(), interactionId: z.string() }),
|
||||
body: jsonBody(submitIssueThreadInteractionVerdictsSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/children",
|
||||
|
||||
@@ -9,11 +9,19 @@ import { collapseDuplicatePendingHumanJoinRequests } from "../lib/join-request-d
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
function buildDismissedAtByKey(
|
||||
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
|
||||
dismissals: Array<{ itemKey: string; kind: string; dismissedAt: Date | string; snoozedUntil: Date | string | null }>,
|
||||
): Map<string, number> {
|
||||
return new Map(
|
||||
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
|
||||
);
|
||||
const now = Date.now();
|
||||
const entries: Array<[string, number]> = [];
|
||||
for (const dismissal of dismissals) {
|
||||
if (dismissal.kind === "snooze") {
|
||||
const snoozedUntil = dismissal.snoozedUntil ? new Date(dismissal.snoozedUntil).getTime() : 0;
|
||||
if (Number.isFinite(snoozedUntil) && snoozedUntil > now) entries.push([dismissal.itemKey, Number.MAX_SAFE_INTEGER]);
|
||||
continue;
|
||||
}
|
||||
entries.push([dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]);
|
||||
}
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
@@ -59,7 +67,12 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
const dismissedAtByKey =
|
||||
req.actor.type === "board" && req.actor.userId
|
||||
? await db
|
||||
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
|
||||
.select({
|
||||
itemKey: inboxDismissals.itemKey,
|
||||
kind: inboxDismissals.kind,
|
||||
dismissedAt: inboxDismissals.dismissedAt,
|
||||
snoozedUntil: inboxDismissals.snoozedUntil,
|
||||
})
|
||||
.from(inboxDismissals)
|
||||
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
|
||||
.then(buildDismissedAtByKey)
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
import { and, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
approvals,
|
||||
assets,
|
||||
companies,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
inboxDismissals,
|
||||
invites,
|
||||
issueApprovals,
|
||||
issueAttachments,
|
||||
issueDocuments,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
joinRequests,
|
||||
documents,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import { deriveProjectUrlKey } from "@paperclipai/shared";
|
||||
import type {
|
||||
AttentionDecisionVerb,
|
||||
AttentionFeed,
|
||||
AttentionDetailImage,
|
||||
AttentionItem,
|
||||
AttentionItemDetail,
|
||||
AttentionProjectRef,
|
||||
AttentionSeverity,
|
||||
AttentionSourceKind,
|
||||
AttentionSubject,
|
||||
AttentionWorkspaceRef,
|
||||
} from "@paperclipai/shared";
|
||||
import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js";
|
||||
import { budgetService } from "./budgets.js";
|
||||
@@ -65,6 +77,8 @@ const OPEN_RECOVERY_STATUSES = ["active", "escalated"] as const;
|
||||
const HUMAN_RECOVERY_OWNER_TYPES = ["user", "board"] as const;
|
||||
const PRODUCTIVITY_REVIEW_TERMINAL_STATUSES = ["done", "cancelled"] as const;
|
||||
const FAILED_RUN_STATUSES = ["failed", "timed_out"] as const;
|
||||
const DETAIL_EXCERPT_LENGTH = 160;
|
||||
const DETAIL_IMAGE_LIMIT = 3;
|
||||
|
||||
type IssueSummaryRow = {
|
||||
id: string;
|
||||
@@ -77,6 +91,27 @@ type IssueSummaryRow = {
|
||||
assigneeUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
project: AttentionProjectRef | null;
|
||||
workspace: AttentionWorkspaceRef | null;
|
||||
};
|
||||
|
||||
type IssueSubjectRow = Omit<IssueSummaryRow, "project" | "workspace">;
|
||||
|
||||
type DismissalState = {
|
||||
kind: "dismiss" | "snooze";
|
||||
dismissedAt: Date;
|
||||
snoozedUntil: Date | null;
|
||||
};
|
||||
|
||||
type PlanDocumentSummary = {
|
||||
title: string | null;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type BlockingIssueSummary = {
|
||||
id: string | null;
|
||||
identifier: string | null;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
type AttentionListOptions = {
|
||||
@@ -99,20 +134,158 @@ function timestamp(value: Date | string | null | undefined): number {
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function isDismissed(
|
||||
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||
function activeDismissalState(
|
||||
dismissalByKey: ReadonlyMap<string, DismissalState>,
|
||||
dismissalKey: string,
|
||||
activityAt: string,
|
||||
now: number,
|
||||
) {
|
||||
const dismissedAt = dismissedAtByKey.get(dismissalKey);
|
||||
return dismissedAt != null && dismissedAt >= timestamp(activityAt);
|
||||
const dismissal = dismissalByKey.get(dismissalKey);
|
||||
if (!dismissal) return null;
|
||||
|
||||
const dismissedAt = toIso(dismissal.dismissedAt);
|
||||
const snoozedUntil = dismissal.snoozedUntil ? toIso(dismissal.snoozedUntil) : null;
|
||||
const isActive = dismissal.kind === "snooze"
|
||||
? dismissal.snoozedUntil != null && timestamp(dismissal.snoozedUntil) > now
|
||||
: timestamp(dismissal.dismissedAt) >= timestamp(activityAt);
|
||||
|
||||
return {
|
||||
kind: dismissal.kind,
|
||||
dismissedAt,
|
||||
snoozedUntil,
|
||||
isActive,
|
||||
};
|
||||
}
|
||||
|
||||
function issueHref(prefix: string, issue: Pick<IssueSummaryRow, "id" | "identifier">) {
|
||||
function stripMarkdown(value: string) {
|
||||
return value
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/[>*_~#-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function excerpt(value: unknown, maxLength = DETAIL_EXCERPT_LENGTH) {
|
||||
if (typeof value !== "string") return null;
|
||||
const cleaned = stripMarkdown(value);
|
||||
if (!cleaned) return null;
|
||||
if (cleaned.length <= maxLength) return cleaned;
|
||||
return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function readArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isPlanDocumentTarget(payload: Record<string, unknown>) {
|
||||
const target = readRecord(payload.target);
|
||||
return target.type === "issue_document" && target.key === "plan";
|
||||
}
|
||||
|
||||
function issueContext(issue: IssueSummaryRow | null | undefined) {
|
||||
return {
|
||||
project: issue?.project ?? null,
|
||||
workspace: issue?.workspace ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function issueImages(imageMap: ReadonlyMap<string, AttentionDetailImage[]>, issueId: string | null | undefined) {
|
||||
return issueId ? imageMap.get(issueId) ?? [] : [];
|
||||
}
|
||||
|
||||
function genericDetail(summary: unknown, images: AttentionDetailImage[]): AttentionItemDetail {
|
||||
return { kind: "generic", summaryExcerpt: excerpt(summary), images };
|
||||
}
|
||||
|
||||
function approvalDetail(type: string, payload: Record<string, unknown>): AttentionItemDetail {
|
||||
return {
|
||||
kind: "approval",
|
||||
approvalType: type,
|
||||
summaryExcerpt: excerpt(payload.summary ?? payload.title ?? payload.recommendedAction),
|
||||
images: [],
|
||||
};
|
||||
}
|
||||
|
||||
function interactionDetail(input: {
|
||||
kind: string;
|
||||
payload: Record<string, unknown>;
|
||||
issue: IssueSummaryRow | null;
|
||||
planDocument: PlanDocumentSummary | null;
|
||||
images: AttentionDetailImage[];
|
||||
}): AttentionItemDetail {
|
||||
if (input.kind === "request_confirmation" && isPlanDocumentTarget(input.payload)) {
|
||||
return {
|
||||
kind: "plan_approval",
|
||||
issueTitle: input.issue?.title ?? null,
|
||||
planTitle: input.planDocument?.title ?? "Plan",
|
||||
summaryExcerpt: excerpt(input.planDocument?.body ?? input.payload.detailsMarkdown ?? input.payload.prompt),
|
||||
images: input.images,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.kind === "ask_user_questions") {
|
||||
const questions = readArray(input.payload.questions).map(readRecord);
|
||||
return {
|
||||
kind: "questions",
|
||||
questionCount: questions.length,
|
||||
firstQuestionText: readString(questions[0]?.prompt),
|
||||
images: input.images,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.kind === "suggest_tasks") {
|
||||
const tasks = readArray(input.payload.tasks).map(readRecord);
|
||||
return {
|
||||
kind: "suggested_tasks",
|
||||
taskCount: tasks.length,
|
||||
firstTaskTitle: readString(tasks[0]?.title),
|
||||
images: input.images,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.kind === "request_checkbox_confirmation") {
|
||||
return {
|
||||
kind: "checkbox_confirmation",
|
||||
optionCount: readArray(input.payload.options).length,
|
||||
promptExcerpt: excerpt(input.payload.prompt),
|
||||
images: input.images,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.kind === "request_item_verdicts") {
|
||||
return {
|
||||
kind: "item_verdicts",
|
||||
itemCount: readArray(input.payload.items).length,
|
||||
promptExcerpt: excerpt(input.payload.prompt),
|
||||
images: input.images,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "confirmation",
|
||||
promptExcerpt: excerpt(input.payload.prompt ?? input.payload.detailsMarkdown),
|
||||
isPlanTarget: false,
|
||||
images: input.images,
|
||||
};
|
||||
}
|
||||
|
||||
function issueHref(prefix: string, issue: Pick<IssueSubjectRow, "id" | "identifier">) {
|
||||
return `/${prefix}/issues/${issue.identifier ?? issue.id}`;
|
||||
}
|
||||
|
||||
function issueSubject(prefix: string, issue: IssueSummaryRow): AttentionSubject {
|
||||
function issueSubject(prefix: string, issue: IssueSubjectRow): AttentionSubject {
|
||||
return {
|
||||
kind: "issue",
|
||||
id: issue.id,
|
||||
@@ -137,22 +310,32 @@ function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb
|
||||
return verbs;
|
||||
}
|
||||
|
||||
function createItem(input: Omit<AttentionItem, "id" | "dismissalKey" | "rank">): AttentionItem {
|
||||
type CreateAttentionItemInput = Omit<AttentionItem, "id" | "dismissalKey" | "rank" | "dismissal" | "project" | "workspace" | "detail"> & {
|
||||
project?: AttentionProjectRef | null;
|
||||
workspace?: AttentionWorkspaceRef | null;
|
||||
detail?: AttentionItemDetail | null;
|
||||
};
|
||||
|
||||
function createItem(input: CreateAttentionItemInput): AttentionItem {
|
||||
return {
|
||||
...input,
|
||||
id: itemId(input.sourceKind, input.dedupKey),
|
||||
dismissalKey: `attention:${input.dedupKey}`,
|
||||
dismissal: null,
|
||||
project: input.project ?? null,
|
||||
workspace: input.workspace ?? null,
|
||||
detail: input.detail ?? null,
|
||||
rank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function compareAttentionItems(left: AttentionItem, right: AttentionItem) {
|
||||
const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
const severityDiff = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity];
|
||||
if (severityDiff !== 0) return severityDiff;
|
||||
const sourceDiff = SOURCE_RANK[left.sourceKind] - SOURCE_RANK[right.sourceKind];
|
||||
if (sourceDiff !== 0) return sourceDiff;
|
||||
const timeDiff = timestamp(right.activityAt) - timestamp(left.activityAt);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return left.dedupKey.localeCompare(right.dedupKey);
|
||||
}
|
||||
|
||||
@@ -178,6 +361,8 @@ function interactionLabel(kind: string) {
|
||||
return "Questions need answers";
|
||||
case "suggest_tasks":
|
||||
return "Suggested tasks need a decision";
|
||||
case "request_item_verdicts":
|
||||
return "Item verdicts need a decision";
|
||||
default:
|
||||
return "Interaction needs a decision";
|
||||
}
|
||||
@@ -218,13 +403,22 @@ async function companyPrefix(db: Db, companyId: string) {
|
||||
return row?.issuePrefix ?? "PAP";
|
||||
}
|
||||
|
||||
async function dismissedAtByKey(db: Db, companyId: string, userId: string | null | undefined) {
|
||||
if (!userId) return new Map<string, number>();
|
||||
async function dismissalByKey(db: Db, companyId: string, userId: string | null | undefined) {
|
||||
if (!userId) return new Map<string, DismissalState>();
|
||||
const rows = await db
|
||||
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
|
||||
.select({
|
||||
itemKey: inboxDismissals.itemKey,
|
||||
kind: inboxDismissals.kind,
|
||||
dismissedAt: inboxDismissals.dismissedAt,
|
||||
snoozedUntil: inboxDismissals.snoozedUntil,
|
||||
})
|
||||
.from(inboxDismissals)
|
||||
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)));
|
||||
return new Map(rows.map((row) => [row.itemKey, timestamp(row.dismissedAt)]));
|
||||
return new Map(rows.map((row) => [row.itemKey, {
|
||||
kind: row.kind,
|
||||
dismissedAt: row.dismissedAt,
|
||||
snoozedUntil: row.snoozedUntil,
|
||||
}]));
|
||||
}
|
||||
|
||||
async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
@@ -242,10 +436,117 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdAt: issues.createdAt,
|
||||
updatedAt: issues.updatedAt,
|
||||
projectId: projects.id,
|
||||
projectName: projects.name,
|
||||
workspaceId: projectWorkspaces.id,
|
||||
workspaceName: projectWorkspaces.name,
|
||||
})
|
||||
.from(issues)
|
||||
.leftJoin(projects, and(eq(issues.projectId, projects.id), eq(projects.companyId, companyId)))
|
||||
.leftJoin(projectWorkspaces, and(
|
||||
eq(issues.projectWorkspaceId, projectWorkspaces.id),
|
||||
eq(projectWorkspaces.companyId, companyId),
|
||||
))
|
||||
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), isNull(issues.hiddenAt)));
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
return new Map(rows.map((row) => [row.id, {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
project: row.projectId && row.projectName ? {
|
||||
id: row.projectId,
|
||||
name: row.projectName,
|
||||
urlKey: deriveProjectUrlKey(row.projectName, row.projectId),
|
||||
} : null,
|
||||
workspace: row.workspaceId && row.workspaceName ? {
|
||||
id: row.workspaceId,
|
||||
name: row.workspaceName,
|
||||
} : null,
|
||||
}]));
|
||||
}
|
||||
|
||||
async function issueImageMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, AttentionDetailImage[]>();
|
||||
const rows = await db
|
||||
.select({
|
||||
issueId: issueAttachments.issueId,
|
||||
assetId: issueAttachments.assetId,
|
||||
originalFilename: assets.originalFilename,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(and(
|
||||
eq(issueAttachments.companyId, companyId),
|
||||
eq(assets.companyId, companyId),
|
||||
inArray(issueAttachments.issueId, ids),
|
||||
sql`${assets.contentType} like 'image/%'`,
|
||||
))
|
||||
.orderBy(asc(issueAttachments.issueId), asc(issueAttachments.createdAt), asc(issueAttachments.id));
|
||||
|
||||
const map = new Map<string, AttentionDetailImage[]>();
|
||||
for (const row of rows) {
|
||||
const images = map.get(row.issueId) ?? [];
|
||||
if (images.length >= DETAIL_IMAGE_LIMIT) continue;
|
||||
images.push({ assetId: row.assetId, alt: row.originalFilename ?? null });
|
||||
map.set(row.issueId, images);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function planDocumentMap(db: Db, companyId: string, issueIds: Array<string | null | undefined>) {
|
||||
const ids = [...new Set(issueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, PlanDocumentSummary>();
|
||||
const rows = await db
|
||||
.select({
|
||||
issueId: issueDocuments.issueId,
|
||||
title: documents.title,
|
||||
body: documents.latestBody,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
|
||||
.where(and(
|
||||
eq(issueDocuments.companyId, companyId),
|
||||
eq(documents.companyId, companyId),
|
||||
eq(issueDocuments.key, "plan"),
|
||||
inArray(issueDocuments.issueId, ids),
|
||||
));
|
||||
return new Map(rows.map((row) => [row.issueId, { title: row.title, body: row.body }]));
|
||||
}
|
||||
|
||||
async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Array<string | null | undefined>) {
|
||||
const ids = [...new Set(blockedIssueIds.filter((value): value is string => Boolean(value)))];
|
||||
if (ids.length === 0) return new Map<string, BlockingIssueSummary>();
|
||||
const rows = await db
|
||||
.select({
|
||||
blockedIssueId: issueRelations.relatedIssueId,
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, ids),
|
||||
isNull(issues.hiddenAt),
|
||||
))
|
||||
.orderBy(asc(issueRelations.relatedIssueId), asc(issueRelations.createdAt), asc(issueRelations.id));
|
||||
const map = new Map<string, BlockingIssueSummary>();
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.blockedIssueId)) {
|
||||
map.set(row.blockedIssueId, { id: row.id, identifier: row.identifier, title: row.title });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function readRunIssueId(contextSnapshot: Record<string, unknown> | null) {
|
||||
@@ -257,13 +558,15 @@ export function attentionService(db: Db) {
|
||||
return {
|
||||
list: async (companyId: string, options: AttentionListOptions = {}): Promise<AttentionFeed> => {
|
||||
const prefix = await companyPrefix(db, companyId);
|
||||
const dismissed = await dismissedAtByKey(db, companyId, options.userId);
|
||||
const dismissals = await dismissalByKey(db, companyId, options.userId);
|
||||
const includeDismissed = options.includeDismissed === true;
|
||||
const now = Date.now();
|
||||
const collected: AttentionItem[] = [];
|
||||
|
||||
const add = (item: AttentionItem) => {
|
||||
if (!includeDismissed && isDismissed(dismissed, item.dismissalKey, item.activityAt)) return;
|
||||
collected.push(item);
|
||||
const dismissal = activeDismissalState(dismissals, item.dismissalKey, item.activityAt, now);
|
||||
if (!includeDismissed && dismissal?.isActive) return;
|
||||
collected.push({ ...item, dismissal });
|
||||
};
|
||||
|
||||
const pendingApprovals = await db
|
||||
@@ -316,6 +619,7 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(approval.createdAt),
|
||||
updatedAt: toIso(approval.updatedAt),
|
||||
relatedIssue: null,
|
||||
detail: approvalDetail(approval.type, approval.payload),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -327,6 +631,7 @@ export function attentionService(db: Db) {
|
||||
status: issueThreadInteractions.status,
|
||||
title: issueThreadInteractions.title,
|
||||
summary: issueThreadInteractions.summary,
|
||||
payload: issueThreadInteractions.payload,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
updatedAt: issueThreadInteractions.updatedAt,
|
||||
})
|
||||
@@ -337,9 +642,20 @@ export function attentionService(db: Db) {
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id));
|
||||
const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId));
|
||||
|
||||
for (const interaction of interactionRows) {
|
||||
const issue = interactionIssueMap.get(interaction.issueId) ?? null;
|
||||
const payload = readRecord(interaction.payload);
|
||||
const detail = interactionDetail({
|
||||
kind: interaction.kind,
|
||||
payload,
|
||||
issue,
|
||||
planDocument: interactionPlanDocumentMap.get(interaction.issueId) ?? null,
|
||||
images: issueImages(interactionImageMap, interaction.issueId),
|
||||
});
|
||||
const isPlanTarget = detail.kind === "plan_approval";
|
||||
const dedupKey = `interaction:${interaction.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
@@ -348,11 +664,16 @@ export function attentionService(db: Db) {
|
||||
kind: "interaction",
|
||||
id: interaction.id,
|
||||
companyId,
|
||||
title: interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind),
|
||||
title: isPlanTarget && issue ? `Plan approval - ${issue.title}` : interaction.title ?? interaction.summary ?? interactionLabel(interaction.kind),
|
||||
identifier: null,
|
||||
status: interaction.status,
|
||||
href: issue ? `${issueHref(prefix, issue)}#interaction-${interaction.id}` : null,
|
||||
metadata: { kind: interaction.kind, issueId: interaction.issueId },
|
||||
metadata: {
|
||||
kind: interaction.kind,
|
||||
issueId: interaction.issueId,
|
||||
isPlanTarget,
|
||||
targetDocumentKey: isPlanTarget ? "plan" : null,
|
||||
},
|
||||
},
|
||||
whyNow: `${interactionLabel(interaction.kind)} on an issue thread.`,
|
||||
decisionVerbs: interactionVerbs(interaction.kind),
|
||||
@@ -365,6 +686,8 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(interaction.createdAt),
|
||||
updatedAt: toIso(interaction.updatedAt),
|
||||
relatedIssue: issue ? issueSubject(prefix, issue) : null,
|
||||
...issueContext(issue),
|
||||
detail,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -425,6 +748,7 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(join.createdAt),
|
||||
updatedAt: toIso(join.updatedAt),
|
||||
relatedIssue: null,
|
||||
detail: genericDetail(label, []),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -442,6 +766,7 @@ export function attentionService(db: Db) {
|
||||
companyId,
|
||||
recoveryRows.flatMap((row) => [row.sourceIssueId, row.recoveryIssueId]),
|
||||
);
|
||||
const recoveryImageMap = await issueImageMap(db, companyId, recoveryRows.map((row) => row.sourceIssueId));
|
||||
|
||||
for (const recovery of recoveryRows) {
|
||||
const sourceIssue = recoveryIssueMap.get(recovery.sourceIssueId) ?? null;
|
||||
@@ -484,6 +809,8 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(recovery.createdAt),
|
||||
updatedAt: toIso(recovery.updatedAt),
|
||||
relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
|
||||
...issueContext(sourceIssue),
|
||||
detail: genericDetail(recovery.nextAction, issueImages(recoveryImageMap, recovery.sourceIssueId)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -512,9 +839,12 @@ export function attentionService(db: Db) {
|
||||
))
|
||||
.orderBy(desc(issues.updatedAt), desc(issues.id));
|
||||
const productivitySourceMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.originId));
|
||||
const productivityReviewMap = await issueSummaryMap(db, companyId, productivityRows.map((row) => row.id));
|
||||
const productivityImageMap = await issueImageMap(db, companyId, productivityRows.map((row) => row.id));
|
||||
|
||||
for (const review of productivityRows) {
|
||||
const reviewIssue: IssueSummaryRow = review;
|
||||
const reviewIssue = productivityReviewMap.get(review.id);
|
||||
if (!reviewIssue) continue;
|
||||
const sourceIssue = review.originId ? productivitySourceMap.get(review.originId) ?? null : null;
|
||||
const dedupKey = `productivity_review:${review.originFingerprint ?? review.originId ?? review.id}`;
|
||||
add(createItem({
|
||||
@@ -536,19 +866,27 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(review.createdAt),
|
||||
updatedAt: toIso(review.updatedAt),
|
||||
relatedIssue: sourceIssue ? issueSubject(prefix, sourceIssue) : null,
|
||||
...issueContext(reviewIssue),
|
||||
detail: genericDetail(sourceIssue?.title ?? review.title, issueImages(productivityImageMap, review.id)),
|
||||
}));
|
||||
}
|
||||
|
||||
const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true });
|
||||
for (const issue of blockedIssues as Array<IssueSummaryRow & { blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null } | null }>) {
|
||||
const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id));
|
||||
const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id));
|
||||
const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id));
|
||||
for (const issue of blockedIssues as Array<IssueSubjectRow & { blockerAttention?: { state?: string; sampleStalledBlockerIdentifier?: string | null; sampleBlockerIdentifier?: string | null } | null }>) {
|
||||
const blockerAttention = issue.blockerAttention;
|
||||
if (blockerAttention?.state !== "stalled") continue;
|
||||
const issueSummary = blockedIssueSummaries.get(issue.id) ?? null;
|
||||
const summarizedIssue = issueSummary ?? issue;
|
||||
const sample = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier ?? issue.identifier ?? issue.id;
|
||||
const blockingIssue = blockingIssues.get(issue.id) ?? { id: null, identifier: sample, title: null };
|
||||
const dedupKey = `blocker:${issue.id}:${sample}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
sourceKind: "blocker_attention",
|
||||
subject: issueSubject(prefix, issue),
|
||||
subject: issueSubject(prefix, summarizedIssue),
|
||||
whyNow: "Blocked dependency chain is stalled and needs a human to choose the next owner or action.",
|
||||
decisionVerbs: decisionVerbs(
|
||||
{ id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." },
|
||||
@@ -564,6 +902,8 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(issue.createdAt),
|
||||
updatedAt: toIso(issue.updatedAt),
|
||||
relatedIssue: null,
|
||||
...issueContext(issueSummary),
|
||||
detail: { kind: "blocker", blockingIssue, images: issueImages(blockedImageMap, issue.id) },
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -598,6 +938,8 @@ export function attentionService(db: Db) {
|
||||
eq(approvals.status, "pending"),
|
||||
));
|
||||
const pendingApprovalByIssueId = new Map(pendingReviewApprovalRows.map((row) => [row.issueId, row.approvalId]));
|
||||
const reviewIssueMap = await issueSummaryMap(db, companyId, reviewIssueIds);
|
||||
const reviewImageMap = await issueImageMap(db, companyId, reviewIssueIds);
|
||||
|
||||
for (const review of reviewRows) {
|
||||
const state = parseIssueExecutionState(review.executionState);
|
||||
@@ -605,7 +947,8 @@ export function attentionService(db: Db) {
|
||||
const hasHumanParticipant = currentParticipant?.type === "user";
|
||||
const pendingApprovalId = pendingApprovalByIssueId.get(review.id) ?? null;
|
||||
if (!hasHumanParticipant && !review.assigneeUserId && !pendingApprovalId) continue;
|
||||
const issue: IssueSummaryRow = review;
|
||||
const issue = reviewIssueMap.get(review.id);
|
||||
if (!issue) continue;
|
||||
const dedupKey = `review:${review.id}`;
|
||||
add(createItem({
|
||||
companyId,
|
||||
@@ -629,6 +972,8 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(review.createdAt),
|
||||
updatedAt: toIso(review.updatedAt),
|
||||
relatedIssue: null,
|
||||
...issueContext(issue),
|
||||
detail: genericDetail(review.title, issueImages(reviewImageMap, review.id)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -666,11 +1011,13 @@ export function attentionService(db: Db) {
|
||||
if (!latestExhaustedByRunId.has(row.id)) latestExhaustedByRunId.set(row.id, row);
|
||||
}
|
||||
const failedRows = [...latestExhaustedByRunId.values()];
|
||||
const failedIssueIds = failedRows.map((row) => readRunIssueId(row.contextSnapshot));
|
||||
const failedIssueMap = await issueSummaryMap(
|
||||
db,
|
||||
companyId,
|
||||
failedRows.map((row) => readRunIssueId(row.contextSnapshot)),
|
||||
failedIssueIds,
|
||||
);
|
||||
const failedImageMap = await issueImageMap(db, companyId, failedIssueIds);
|
||||
for (const run of failedRows) {
|
||||
const issueId = readRunIssueId(run.contextSnapshot);
|
||||
const newer = await db
|
||||
@@ -722,6 +1069,13 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(run.createdAt),
|
||||
updatedAt: toIso(run.updatedAt),
|
||||
relatedIssue: issue ? issueSubject(prefix, issue) : null,
|
||||
...issueContext(issue),
|
||||
detail: {
|
||||
kind: "failed_run",
|
||||
agentName: run.agentName,
|
||||
failureReasonExcerpt: excerpt(run.error ?? run.exhaustionMessage ?? run.errorCode),
|
||||
images: issueImages(failedImageMap, issueId),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -769,6 +1123,13 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(incident.createdAt),
|
||||
updatedAt: toIso(incident.updatedAt),
|
||||
relatedIssue: null,
|
||||
detail: {
|
||||
kind: "budget",
|
||||
observedPercent,
|
||||
amountObserved: incident.amountObserved,
|
||||
amountLimit: incident.amountLimit,
|
||||
images: [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -816,6 +1177,12 @@ export function attentionService(db: Db) {
|
||||
createdAt: toIso(agent.createdAt),
|
||||
updatedAt: toIso(agent.updatedAt),
|
||||
relatedIssue: null,
|
||||
detail: {
|
||||
kind: "agent_error",
|
||||
agentName: agent.name,
|
||||
failureReasonExcerpt: excerpt(agent.errorReason),
|
||||
images: [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { inboxDismissals } from "@paperclipai/db";
|
||||
import type { InboxDismissalKind } from "@paperclipai/shared";
|
||||
|
||||
export function inboxDismissalService(db: Db) {
|
||||
async function upsert(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
itemKey: string,
|
||||
input: { kind: InboxDismissalKind; dismissedAt?: Date; snoozedUntil?: Date | null },
|
||||
) {
|
||||
const now = new Date();
|
||||
const dismissedAt = input.dismissedAt ?? now;
|
||||
const snoozedUntil = input.kind === "snooze" ? input.snoozedUntil ?? null : null;
|
||||
const [row] = await db
|
||||
.insert(inboxDismissals)
|
||||
.values({
|
||||
companyId,
|
||||
userId,
|
||||
itemKey,
|
||||
kind: input.kind,
|
||||
dismissedAt,
|
||||
snoozedUntil,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
|
||||
set: {
|
||||
kind: input.kind,
|
||||
dismissedAt,
|
||||
snoozedUntil,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
list: async (companyId: string, userId: string) =>
|
||||
db
|
||||
@@ -16,26 +50,26 @@ export function inboxDismissalService(db: Db) {
|
||||
userId: string,
|
||||
itemKey: string,
|
||||
dismissedAt: Date = new Date(),
|
||||
) => {
|
||||
const now = new Date();
|
||||
) => upsert(companyId, userId, itemKey, { kind: "dismiss", dismissedAt }),
|
||||
|
||||
snooze: async (
|
||||
companyId: string,
|
||||
userId: string,
|
||||
itemKey: string,
|
||||
snoozedUntil: Date,
|
||||
dismissedAt: Date = new Date(),
|
||||
) => upsert(companyId, userId, itemKey, { kind: "snooze", dismissedAt, snoozedUntil }),
|
||||
|
||||
restore: async (companyId: string, userId: string, itemKey: string) => {
|
||||
const [row] = await db
|
||||
.insert(inboxDismissals)
|
||||
.values({
|
||||
companyId,
|
||||
userId,
|
||||
itemKey,
|
||||
dismissedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
|
||||
set: {
|
||||
dismissedAt,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.delete(inboxDismissals)
|
||||
.where(and(
|
||||
eq(inboxDismissals.companyId, companyId),
|
||||
eq(inboxDismissals.userId, userId),
|
||||
eq(inboxDismissals.itemKey, itemKey),
|
||||
))
|
||||
.returning();
|
||||
return row;
|
||||
return row ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||