diff --git a/packages/core/src/inbox/engagement.test.ts b/packages/core/src/inbox/engagement.test.ts index 40c2c30a3..4256dced8 100644 --- a/packages/core/src/inbox/engagement.test.ts +++ b/packages/core/src/inbox/engagement.test.ts @@ -1,6 +1,7 @@ import type { SignalReport } from "@posthog/shared/types"; import { describe, expect, it } from "vitest"; import { + buildBulkActionEvents, buildInboxViewedProperties, type InboxDetailTab, inboxDetailTabReports, @@ -33,6 +34,59 @@ const NO_FILTERS = { isDefaultScope: true, }; +describe("buildBulkActionEvents", () => { + it("marks single-report actions as non-bulk", () => { + const [event, ...rest] = buildBulkActionEvents({ + reports: [fakeReport({ id: "a", priority: "P1" })], + actionType: "snooze", + surface: "detail_pane", + }); + + expect(rest).toHaveLength(0); + expect(event).toMatchObject({ + report_id: "a", + action_type: "snooze", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + priority: "P1", + }); + }); + + it("emits one bulk-flagged event per report", () => { + const events = buildBulkActionEvents({ + reports: [fakeReport({ id: "a" }), fakeReport({ id: "b" })], + actionType: "delete", + surface: "toolbar", + }); + + expect(events.map((e) => e.report_id)).toEqual(["a", "b"]); + expect(events.every((e) => e.is_bulk && e.bulk_size === 2)).toBe(true); + expect(events.every((e) => e.action_type === "delete")).toBe(true); + }); + + it("attaches dismissal reason/note only for dismiss, truncating the note", () => { + const longNote = "x".repeat(600); + const [dismissed] = buildBulkActionEvents({ + reports: [fakeReport({ id: "a" })], + actionType: "dismiss", + surface: "toolbar", + dismissal: { reason: "not_relevant", note: longNote }, + }); + expect(dismissed.dismissal_reason).toBe("not_relevant"); + expect(dismissed.dismissal_note).toHaveLength(500); + + const [snoozed] = buildBulkActionEvents({ + reports: [fakeReport({ id: "a" })], + actionType: "snooze", + surface: "toolbar", + dismissal: { reason: "not_relevant", note: longNote }, + }); + expect(snoozed.dismissal_reason).toBeUndefined(); + expect(snoozed.dismissal_note).toBeUndefined(); + }); +}); + describe("buildInboxViewedProperties", () => { it("counts visible reports, tab badges, and total", () => { const props = buildInboxViewedProperties({ diff --git a/packages/core/src/inbox/engagement.ts b/packages/core/src/inbox/engagement.ts index d6f68eab1..bb31915e3 100644 --- a/packages/core/src/inbox/engagement.ts +++ b/packages/core/src/inbox/engagement.ts @@ -1,5 +1,6 @@ import type { InboxReportActionProperties, + InboxReportActionSurface, InboxViewedProperties, } from "@posthog/shared/analytics-events"; import type { SignalReport } from "@posthog/shared/domain-types"; @@ -119,6 +120,56 @@ export function resolveActionProperties( return { rank, list_size: listSize, priority, actionability }; } +/** Bulk-capable report actions fired from the selection toolbar / dismiss flows. */ +export type InboxBulkActionType = Extract< + InboxReportActionProperties["action_type"], + "dismiss" | "snooze" | "delete" | "reingest" +>; + +export interface BuildBulkActionEventsInput { + /** Reports the action actually succeeded for (one event is built per report). */ + reports: SignalReport[]; + actionType: InboxBulkActionType; + surface: InboxReportActionSurface; + /** Dismissal metadata, only meaningful for `dismiss`. Note is truncated to 500 chars. */ + dismissal?: { reason?: string; note?: string }; +} + +/** + * Build `INBOX_REPORT_ACTION` payloads for a bulk (or single-report) dismiss / + * snooze / delete / reingest. Pure so it can be unit-tested and reused across + * the toolbar, the per-row dismiss action, and detail-screen dismiss. + * + * `is_bulk` / `bulk_size` carry the grouping; `rank` / `list_size` are left at 0 + * because these flows act on a selection, not a positional list slot. + */ +export function buildBulkActionEvents( + input: BuildBulkActionEventsInput, +): InboxReportActionProperties[] { + const { reports, actionType, surface, dismissal } = input; + const bulkSize = reports.length; + const isBulk = bulkSize > 1; + return reports.map((report) => ({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: reportAgeHours(report.created_at), + priority: report.priority ?? null, + actionability: report.actionability ?? null, + action_type: actionType, + surface, + is_bulk: isBulk, + bulk_size: bulkSize, + rank: 0, + list_size: 0, + ...(actionType === "dismiss" && dismissal?.reason + ? { dismissal_reason: dismissal.reason } + : {}), + ...(actionType === "dismiss" && dismissal?.note + ? { dismissal_note: dismissal.note.slice(0, 500) } + : {}), + })); +} + export interface InboxViewedFilterState { sourceProductFilter: string[]; priorityFilter: string[]; diff --git a/packages/ui/src/features/inbox/components/InboxReportListTab.tsx b/packages/ui/src/features/inbox/components/InboxReportListTab.tsx index 7841f3cba..a51f5749c 100644 --- a/packages/ui/src/features/inbox/components/InboxReportListTab.tsx +++ b/packages/ui/src/features/inbox/components/InboxReportListTab.tsx @@ -128,7 +128,11 @@ export function InboxReportListTab({ ); const dismissTargetId = dismissReport?.id ?? null; - const dismissBulkActions = useInboxBulkActions(allReports, dismissTargetId); + const dismissBulkActions = useInboxBulkActions( + allReports, + dismissTargetId, + "list_row", + ); const handleDismissDialogOpenChange = useCallback((open: boolean) => { if (!open) setDismissReport(null); diff --git a/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts b/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts index 0c280aab5..25e73c359 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts @@ -1,9 +1,16 @@ +import { + buildBulkActionEvents, + type InboxBulkActionType, +} from "@posthog/core/inbox/engagement"; import { inboxStatusLabel } from "@posthog/core/inbox/reportPresentation"; +import type { InboxReportActionSurface } from "@posthog/shared/analytics-events"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { SignalReport } from "@posthog/shared/types"; import type { DismissReportDialogResult } from "@posthog/ui/features/inbox/components/DismissReportDialog"; import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports"; import { useInboxReportSelectionStore } from "@posthog/ui/features/inbox/stores/inboxReportSelectionStore"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { track } from "@posthog/ui/shell/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { toast } from "sonner"; @@ -187,6 +194,7 @@ export function buildSuppressDisabledReasonMap( export function useInboxBulkActions( reports: SignalReport[], selection: InboxBulkSelection, + surface: InboxReportActionSurface = "toolbar", ) { const queryClient = useQueryClient(); const clearSelection = useInboxReportSelectionStore( @@ -196,6 +204,38 @@ export function useInboxBulkActions( (state) => state.removeFromSelection, ); + /** + * Emit one `INBOX_REPORT_ACTION` per report the action succeeded for. Resolves + * report metadata from the pre-mutation list (the query is invalidated right + * after, dropping the affected reports), and skips firing when nothing landed. + */ + const trackBulkAction = useCallback( + ( + actionType: InboxBulkActionType, + result: BulkActionResult, + dismissal?: DismissReportDialogResult, + ) => { + if (result.successCount === 0) return; + const byId = new Map(reports.map((report) => [report.id, report])); + const succeeded = result.succeededIds + .map((id) => byId.get(id)) + .filter((report): report is SignalReport => report !== undefined); + if (succeeded.length === 0) return; + const events = buildBulkActionEvents({ + reports: succeeded, + actionType, + surface, + dismissal: dismissal + ? { reason: dismissal.reason, note: dismissal.note } + : undefined, + }); + for (const event of events) { + track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, event); + } + }, + [reports, surface], + ); + /** * Reflect a bulk-action result in the selection: drop succeeded ids so the * user can retry the failed subset; keep everything if nothing succeeded so @@ -247,7 +287,8 @@ export function useInboxBulkActions( ); }, { - onSuccess: async (result) => { + onSuccess: async (result, variables) => { + trackBulkAction("dismiss", result, variables.dismissal); await invalidateInboxQueries(); applyBulkResultToSelection(result); @@ -274,6 +315,7 @@ export function useInboxBulkActions( ), { onSuccess: async (result) => { + trackBulkAction("snooze", result); await invalidateInboxQueries(); applyBulkResultToSelection(result); @@ -297,6 +339,7 @@ export function useInboxBulkActions( ), { onSuccess: async (result) => { + trackBulkAction("delete", result); await invalidateInboxQueries(); applyBulkResultToSelection(result); @@ -320,6 +363,7 @@ export function useInboxBulkActions( ), { onSuccess: async (result) => { + trackBulkAction("reingest", result); await invalidateInboxQueries(); applyBulkResultToSelection(result); diff --git a/packages/ui/src/features/inbox/hooks/useInboxReportDismissAction.tsx b/packages/ui/src/features/inbox/hooks/useInboxReportDismissAction.tsx index 55d53bcaa..f463871bc 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReportDismissAction.tsx +++ b/packages/ui/src/features/inbox/hooks/useInboxReportDismissAction.tsx @@ -28,6 +28,7 @@ export function useInboxReportDismissAction(report: SignalReport): { const bulkActions = useInboxBulkActions( reportsForActions, open ? report.id : null, + "detail_pane", ); const isPending = bulkActions.isSuppressing || bulkActions.isSnoozing;