Enrich attention feed and add snooze dismissals

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-10 20:08:18 +00:00
1 parent 8804ee9bba
commit 9dcdb3ce60
34 files changed
+1043 -78

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(),
},
+6
View File
@@ -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,
+107
View File
@@ -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;
}
+6 -1
View File
@@ -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,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

+260 -5
View File
@@ -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";
+88 -22
View File
@@ -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;
}
+26 -2
View File
@@ -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",
+18 -5
View File
@@ -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)
+392 -25
View File
@@ -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: [],
},
}));
}
+52 -18
View File
@@ -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;
},
};
}