fix(ui): show task identifiers in activity toasts (#18802)

Project the company-scoped identifier for authorized task events and use it in toast titles, fallback bodies, and links. Retain authorized delivery when the optional identifier lookup fails.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-03 13:28:15 -06:00
1 parent fb6554ac62
commit d8a9b3eb0c
5 files changed
+142 -19

No files matched your search

+4
View File
@@ -63,6 +63,10 @@ never persisted or published on the shared company event bus.
Authorized events still reach cache handling and component subscribers when
eligibility is false. Lookup failure is logged but does not discard the authorized
event. Existing task/run privacy checks and payload redaction remain in force.
Task activity titles, fallback text, and links use the authorized task identifier.
If the optional identifier lookup fails, the event still arrives and uses `Task`
unless the browser already has the identifier. Permission-check failures do not
use this fallback.
When an authenticated comment event omits the comment ID, the visible task thread
refreshes through its authorized HTTP read instead of trying to hydrate the comment.
Closed sockets cannot deliver stale notifications after account/company changes.
+80 -7
View File
@@ -29,7 +29,7 @@ function database(onPredicate: (kind: "where" | "join", condition: SQL) => void
innerJoin: (_table: unknown, condition: SQL) => { onPredicate("join", condition); return query; },
where: (condition: SQL) => { onPredicate("where", condition); return query; },
limit: async () => {
if (table === "issues") return [{ id: "task" }];
if (table === "issues") return [{ id: "task", identifier: "PAP-1234" }];
if (table === "company_memberships") return fields.name ? [{ name: "Alex Example", image: "/alex.png" }] : [{ id: "membership" }];
if (table === "agents") return [{ name: "Coder", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "bubblegum-sky" } }];
return [];
@@ -39,6 +39,60 @@ function database(onPredicate: (kind: "where" | "join", condition: SQL) => void
} };
}
describe("authenticated activity notifications", () => {
it.each(["local_trusted", "authenticated"] as const)("keeps authorized notifications when optional identifier reads fail (%s)", async deploymentMode => {
const db = database();
const select = db.select;
const identifierRead = vi.fn();
db.select = fields => {
if (fields.identifier) {
identifierRead();
if (identifierRead.mock.calls.length <= 3) throw new Error("Identifier lookup unavailable");
}
return select(fields);
};
const server = createServer();
setupLiveEventsWebSocketServer(server, db as never, { deploymentMode,
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
const messages: Array<{ payload: Record<string, unknown> }> = [];
const delivered = new Promise<void>(resolve => socket.on("message", data => {
const event = JSON.parse(data.toString());
messages.push(event);
if (event.payload.entityId === "after-failure") resolve();
}));
const activity = (entityId: string, actorType: string) => publishLiveEvent({
companyId: "company", type: "activity.logged", payload: {
action: "issue.updated", entityType: "issue", entityId, actorType,
actorId: actorType === "user" ? "viewer" : actorType === "agent" ? "agent" : null,
issueIdentifier: "SPOOF-1", details: { secret: "private task details" },
},
});
if (deploymentMode === "authenticated") activity("hidden", "system");
for (const actorType of ["system", "agent", "user"]) {
activity("visible", actorType);
}
activity("after-failure", "system");
await delivered;
expect(messages).toHaveLength(4);
expect(identifierRead).toHaveBeenCalledTimes(4);
for (const [index, actorType] of ["system", "agent", "user"].entries()) {
const event = messages[index];
expect(event.payload).toMatchObject({ entityId: "visible", actorType, issueIdentifier: null });
if (actorType === "agent") expect(event.payload.actorName).toBe("Coder");
if (actorType === "user") expect(event.payload.actorName).toBe("Alex Example");
if (deploymentMode === "authenticated") expect(event.payload).not.toHaveProperty("details");
}
expect(messages[3].payload.issueIdentifier).toBe("PAP-1234");
} finally {
socket.terminate();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it("preserves only an authorized handoff task identity for empty case-panel refresh", async () => {
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
@@ -88,6 +142,7 @@ describe("authenticated activity notifications", () => {
} });
const [data] = await delivery;
expect(JSON.parse(data.toString()).toastEligible).toBe(true);
expect(JSON.parse(data.toString()).payload.issueIdentifier).toBe("PAP-1234");
expect(taskToastEligible).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "local-board");
} finally {
socket.terminate();
@@ -123,6 +178,8 @@ describe("authenticated activity notifications", () => {
expect(b.toastEligible).toBe(false);
expect(a.payload.entityId).toBe(entityId);
expect(b.payload.entityId).toBe(entityId);
expect(a.payload.issueIdentifier).toBe("PAP-1234");
expect(b.payload.issueIdentifier).toBe("PAP-1234");
expect(original).not.toHaveProperty("toastEligible");
}
} finally {
@@ -132,9 +189,9 @@ describe("authenticated activity notifications", () => {
}
});
it("keeps actor identity after authorization and excludes hidden tasks and private details", async () => {
it.each(["authenticated", "local_trusted"] as const)("keeps task and actor identity for %s without exposing hidden tasks or copying private details", async (deploymentMode) => {
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode,
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
@@ -145,19 +202,21 @@ describe("authenticated activity notifications", () => {
socket.on("message", (data) => messages.push(JSON.parse(data.toString())));
const activity = (entityId: string, actorType = "user") => publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.created", entityType: "issue", entityId, actorType, actorId: actorType === "user" ? "viewer" : "agent",
issueIdentifier: "SPOOF-1",
details: { bodySnippet: "private comment", secret: "not for the socket" },
} });
activity("hidden");
if (deploymentMode === "authenticated") activity("hidden");
const first = once(socket, "message");
activity("visible");
await first;
expect(messages).toHaveLength(1);
expect(messages[0].payload).toEqual({ action: "issue.created", entityType: "issue", entityId: "visible", actorType: "user", actorId: "viewer", actorName: "Alex Example", actorImage: "/alex.png" });
expect(messages[0].payload).toMatchObject({ action: "issue.created", entityType: "issue", entityId: "visible", issueIdentifier: "PAP-1234", actorType: "user", actorId: "viewer", actorName: "Alex Example", actorImage: "/alex.png" });
if (deploymentMode === "authenticated") expect(messages[0].payload).not.toHaveProperty("details");
const second = once(socket, "message");
activity("visible", "agent");
await second;
expect(messages[1].payload).toMatchObject({ actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
expect(messages[1].payload).not.toHaveProperty("details");
expect(messages[1].payload).toMatchObject({ issueIdentifier: "PAP-1234", actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
if (deploymentMode === "authenticated") expect(messages[1].payload).not.toHaveProperty("details");
} finally {
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -167,6 +226,20 @@ describe("authenticated activity notifications", () => {
describe("activity profile query boundaries", () => {
it("reads the task identifier within its company even when there is no actor", async () => {
const predicates: { sql: string; params: unknown[] }[] = [];
const db = database((_kind, condition) => {
const { sql, params } = new PgDialect().sqlToQuery(condition);
predicates.push({ sql, params });
});
expect(await activityToastIdentity(db as never, "company-A", {
action: "issue.updated", entityType: "issue", entityId: "task-A", actorType: "system",
})).toMatchObject({ issueIdentifier: "PAP-1234" });
expect(predicates).toEqual([
{ sql: '("issues"."company_id" = $1 and "issues"."id" = $2)', params: ["company-A", "task-A"] },
]);
});
it("requires the exact company, human actor, and active membership before reading a person profile", async () => {
const predicates: { kind: string; sql: string; params: unknown[] }[] = [];
const db = database((kind, condition) => {
+22 -3
View File
@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm";
import { agents, authUsers, companyMemberships, type Db } from "@paperclipai/db";
import { agents, authUsers, companyMemberships, issues, type Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
const TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
@@ -11,8 +12,26 @@ export async function activityToastIdentity(
) {
const actorType = typeof payload.actorType === "string" ? payload.actorType : null;
const actorId = typeof payload.actorId === "string" ? payload.actorId : null;
const identity = { actorType, actorId };
if (!actorId || !TOAST_ACTIONS.has(String(payload.action))) return identity;
const actorIdentity = { actorType, actorId };
if (!TOAST_ACTIONS.has(String(payload.action))) return actorIdentity;
const issueId = payload.entityType === "issue" && typeof payload.entityId === "string"
? payload.entityId : null;
let issueIdentifier: string | null = null;
if (issueId) {
try {
const [issue] = await db
.select({ identifier: issues.identifier })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
.limit(1);
issueIdentifier = issue?.identifier ?? null;
} catch (err) {
logger.warn({ err, companyId, issueId }, "task toast identifier lookup failed");
}
}
const identity = { ...actorIdentity, ...(issueId ? { issueIdentifier } : {}) };
if (!actorId) return identity;
if (actorType === "agent") {
const [agent] = await db
+28 -4
View File
@@ -2085,7 +2085,31 @@ describe("task subtree notification context", () => {
describe("activity toast attribution", () => {
const build = __liveUpdatesTestUtils.buildActivityToast;
const payload = { action: "issue.created", entityType: "issue", entityId: "task-1", actorType: "user", actorId: "user-1", actorName: "Alex Example", actorImage: "/alex.png" };
const payload = { action: "issue.created", entityType: "issue", entityId: "task-1", issueIdentifier: "PAP-1234", actorType: "user", actorId: "user-1", actorName: "Alex Example", actorImage: "/alex.png" };
it.each([
["issue.created", "Alex Example created PAP-1234"],
["issue.updated", "Alex Example updated PAP-1234"],
["issue.comment_added", "Alex Example commented on PAP-1234"],
])("uses the server task identifier for %s with an empty cache and no activity details", (action, title) => {
const toast = build(new QueryClient(), "company", { ...payload, action }, { userId: "other-user", agentId: null });
expect(toast?.title).toBe(title);
expect(toast?.action).toEqual({ label: "View PAP-1234", href: "/issues/PAP-1234" });
if (action === "issue.updated") expect(toast?.body).toBe("PAP-1234");
expect(JSON.stringify(toast)).not.toContain("Task task-1");
});
it("uses a generic task label when the identifier is unavailable", () => {
const toast = build(new QueryClient(), "company", { ...payload, action: "issue.updated", issueIdentifier: undefined }, { userId: "other-user", agentId: null });
expect(toast?.title).toBe("Alex Example updated Task");
expect(toast?.body).toBe("Task");
expect(toast?.action).toEqual({ label: "View Task", href: "/issues/task-1" });
});
it("resolves cached task titles by the server identifier", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKeys.issues.detail("PAP-1234"), { id: "task-1", identifier: "PAP-1234", title: "Fix task toasts" });
const toast = build(queryClient, "company", { ...payload, action: "issue.updated" }, { userId: "other-user", agentId: null });
expect(toast?.title).toBe("Alex Example updated PAP-1234");
expect(toast?.body).toBe("Fix task toasts");
});
it.each(["issue.created", "issue.updated", "issue.comment_added"])("hides the viewer's own %s", (action) => {
expect(build(new QueryClient(), "company", { ...payload, action }, { userId: "user-1", agentId: null })).toBeNull();
});
@@ -2094,18 +2118,18 @@ describe("activity toast attribution", () => {
});
it("uses the real name and photo with an empty directory cache", () => {
const toast = build(new QueryClient(), "company", payload, { userId: "other-user", agentId: null });
expect(toast?.title).toBe("Alex Example created Task task-1");
expect(toast?.title).toBe("Alex Example created PAP-1234");
expect(toast?.actor).toMatchObject({ type: "user", name: "Alex Example", image: "/alex.png" });
});
it("uses the agent's custom avatar with an empty agent cache", () => {
const appearance = { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "bubblegum-sky", customAvatarAssetId: "11111111-1111-4111-8111-111111111111" };
const toast = build(new QueryClient(), "company", { ...payload, actorType: "agent", actorId: "agent-1", actorName: "Coder", actorAppearance: appearance }, { userId: "user-1", agentId: null });
expect(toast?.title).toBe("Coder created Task task-1");
expect(toast?.title).toBe("Coder created PAP-1234");
expect(toast?.actor?.appearance).toEqual(appearance);
});
it("keeps system activity distinct from people", () => {
const toast = build(new QueryClient(), "company", { ...payload, actorType: "system", actorName: undefined }, { userId: "user-1", agentId: null });
expect(toast?.title).toBe("System created Task task-1");
expect(toast?.title).toBe("System created PAP-1234");
expect(toast?.actor).toBeUndefined();
});
});
+8 -5
View File
@@ -375,11 +375,11 @@ function resolveIssueToastContext(
issueRefs.some((ref) => issue.id === ref || issue.identifier === ref),
);
const cachedIssue = detailIssue ?? listIssue ?? null;
const ref =
const identifier =
readString(details?.identifier) ??
readString(details?.issueIdentifier) ??
cachedIssue?.identifier ??
`Task ${shortId(issueId)}`;
cachedIssue?.identifier;
const ref = identifier ?? "Task";
const title =
readString(details?.title) ??
readString(details?.issueTitle) ??
@@ -389,7 +389,7 @@ function resolveIssueToastContext(
ref,
title,
label: title ? `${ref} - ${truncate(title, 72)}` : ref,
href: `/issues/${cachedIssue?.identifier ?? issueId}`,
href: `/issues/${identifier ?? issueId}`,
};
}
@@ -1064,7 +1064,10 @@ function buildActivityToast(
queryClient,
companyId,
entityId,
details,
{
...details,
identifier: readString(payload.issueIdentifier) ?? readString(details?.identifier),
},
);
const actor = readString(readString(payload.actorName)?.trim()) ?? resolveActorLabel(queryClient, companyId, actorType, actorId);
const cachedAgent = actorType === "agent" && actorId