diff --git a/convex/publisherAbuse.test.ts b/convex/publisherAbuse.test.ts index 39586af2..06ce2dd8 100644 --- a/convex/publisherAbuse.test.ts +++ b/convex/publisherAbuse.test.ts @@ -367,6 +367,18 @@ const dismissPublisherAbuseSignalHandler = ( > )._handler; +const reviewPublisherAbuseSignalsBatchHandler = ( + publisherAbuse.reviewPublisherAbuseSignalsBatch as unknown as Wrapped< + { + signalIds: string[]; + status: "snoozed" | "dismissed"; + note?: string; + days?: number; + }, + { ok: true; status: "snoozed" | "dismissed"; updated: number } + > +)._handler; + const reopenPublisherAbuseSignalHandler = ( publisherAbuse.reopenPublisherAbuseSignal as unknown as Wrapped< { signalId: string; note?: string }, @@ -1076,6 +1088,123 @@ describe("publisher abuse dry-run persistence", () => { expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.any(Symbol), {}); }); + it("lets moderators snooze a batch of signals with one audited transition per signal", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const signals = new Map( + ["first", "second"].map((suffix, index) => [ + `publisherAbuseSignals:${suffix}`, + { + _id: `publisherAbuseSignals:${suffix}`, + signalType: "sustained_downloads_flat_installs", + ownerKey: `publisher:publishers:${suffix}`, + ownerPublisherId: `publishers:${suffix}`, + ownerUserId: `users:${suffix}`, + handleSnapshot: suffix, + skillId: `skills:${suffix}`, + skillSlug: suffix, + skillDisplayName: suffix, + firstSeenAt: 10, + lastSeenAt: 20, + seenCount: index + 1, + recent7Downloads: 1_000, + recent7Installs: 0, + recent7InstallDownloadRatio: 0, + recent30Downloads: 5_000, + recent30Installs: 0, + recent30InstallDownloadRatio: 0, + allTimeDownloads: 10_000 + index, + allTimeInstalls: 0, + allTimeInstallDownloadRatio: 0, + reviewStatus: "open", + lastChangedAt: 20, + needsNotification: false, + }, + ]), + ); + const patch = vi.fn(async () => null); + const insert = vi.fn(async () => "publisherAbuseSignalReviewEvents:event"); + const now = 1_800_000_000_000; + vi.spyOn(Date, "now").mockReturnValue(now); + + await expect( + reviewPublisherAbuseSignalsBatchHandler( + { + db: { + get: vi.fn(async (id: string) => signals.get(id) ?? null), + patch, + insert, + }, + }, + { + signalIds: ["publisherAbuseSignals:first", "publisherAbuseSignals:second"], + status: "snoozed", + note: "Likely crawler traffic", + days: 30, + }, + ), + ).resolves.toEqual({ ok: true, status: "snoozed", updated: 2 }); + + expect(patch).toHaveBeenCalledTimes(2); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseSignals:first", + expect.objectContaining({ + reviewStatus: "snoozed", + reviewNote: "Likely crawler traffic", + snoozedUntil: now + 30 * 24 * 60 * 60 * 1_000, + evidenceBaselineDownloads: 10_000, + needsNotification: false, + }), + ); + expect(insert).toHaveBeenCalledTimes(2); + expect(insert).toHaveBeenCalledWith( + "publisherAbuseSignalReviewEvents", + expect.objectContaining({ + actorUserId: "users:moderator", + eventType: "snoozed", + previousStatus: "open", + nextStatus: "snoozed", + }), + ); + }); + + it("rejects a bulk review atomically when a selected signal is no longer open", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const signals = new Map([ + ["publisherAbuseSignals:open", { _id: "publisherAbuseSignals:open", reviewStatus: "open" }], + [ + "publisherAbuseSignals:reviewed", + { _id: "publisherAbuseSignals:reviewed", reviewStatus: "snoozed" }, + ], + ]); + const patch = vi.fn(async () => null); + const insert = vi.fn(async () => "publisherAbuseSignalReviewEvents:event"); + + await expect( + reviewPublisherAbuseSignalsBatchHandler( + { + db: { + get: vi.fn(async (id: string) => signals.get(id) ?? null), + patch, + insert, + }, + }, + { + signalIds: ["publisherAbuseSignals:open", "publisherAbuseSignals:reviewed"], + status: "dismissed", + }, + ), + ).rejects.toThrow("One or more selected signals are no longer open; refresh and try again"); + + expect(patch).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + }); + it("does not reopen or notify already-open publisher abuse signals", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", @@ -10010,6 +10139,8 @@ describe("publisher abuse dry-run persistence", () => { allTimeDownloads: 10_000, allTimeInstalls: 1_000, allTimeInstallDownloadRatio: 0.1, + lastChangedAt: 20, + needsNotification: true, }; const signalLookups: Array> = []; const insertedSignals: unknown[] = []; @@ -10115,7 +10246,7 @@ describe("publisher abuse dry-run persistence", () => { ).resolves.toEqual({ archivedCandidates: 3, archivedSignals: 2, - changedSignals: 2, + changedSignals: 1, }); expect(signalLookups).toEqual([ @@ -10139,7 +10270,9 @@ describe("publisher abuse dry-run persistence", () => { recent7InstallDownloadRatio: 0.12, lastSeenAt: 1_234, seenCount: 6, - lastChangedAt: 1_234, + notificationBaselineDownloads: 10_000, + notificationBaselineInstalls: 1_000, + lastChangedAt: 20, needsNotification: true, }), ); @@ -10153,10 +10286,37 @@ describe("publisher abuse dry-run persistence", () => { lastSeenAt: 1_234, seenCount: 1, reviewStatus: "open", + notificationBaselineDownloads: 10_000, + notificationBaselineInstalls: 0, lastChangedAt: 1_234, needsNotification: true, }), ]); + + patch.mockClear(); + highRatio.totalDownloads = 10_500; + highRatio.totalInstalls = 1_050; + + await expect( + archiveTemporalPublisherAbuseSignalsPageHandler(ctx, { + runId: "publisherAbuseScoreRuns:temporal", + candidates: [highRatio], + now: 2_345, + }), + ).resolves.toEqual({ + archivedCandidates: 1, + archivedSignals: 1, + changedSignals: 1, + }); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseSignals:existing-ratio", + expect.objectContaining({ + notificationBaselineDownloads: 10_500, + notificationBaselineInstalls: 1_050, + lastChangedAt: 2_345, + needsNotification: true, + }), + ); }); it("keeps acknowledged evidence quiet and reopens only for fresh post-snooze activity", async () => { diff --git a/convex/publisherAbuse.ts b/convex/publisherAbuse.ts index 4264d370..bf9d1706 100644 --- a/convex/publisherAbuse.ts +++ b/convex/publisherAbuse.ts @@ -76,6 +76,7 @@ const RECURRING_SUSTAINED_SIGNAL_MAX_INSTALLS = 5; const RECURRING_RATIO_SIGNAL_MIN_DOWNLOADS = 500; const RECURRING_RATIO_SIGNAL_MIN_INSTALLS = 50; const RECURRING_RATIO_SIGNAL_MIN_RATIO = 0.1; +const MAX_PUBLISHER_ABUSE_SIGNAL_BATCH_SIZE = 50; const MAX_PUBLISHER_ABUSE_SIGNAL_REVIEW_NOTE_LENGTH = 1000; const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_BATCH_SIZE = 10; const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_MAX_BATCH_SIZE = 25; @@ -656,6 +657,60 @@ export const dismissPublisherAbuseSignal = mutation({ }, }); +export const reviewPublisherAbuseSignalsBatch = mutation({ + args: { + signalIds: v.array(v.id("publisherAbuseSignals")), + status: v.union(v.literal("snoozed"), v.literal("dismissed")), + note: v.optional(v.string()), + days: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { user } = await requireUser(ctx); + assertModerator(user); + const signalIds = [...new Set(args.signalIds)]; + if (signalIds.length === 0) throw new Error("Select at least one publisher abuse signal"); + if (signalIds.length > MAX_PUBLISHER_ABUSE_SIGNAL_BATCH_SIZE) { + throw new Error( + `Review at most ${MAX_PUBLISHER_ABUSE_SIGNAL_BATCH_SIZE} publisher abuse signals at once`, + ); + } + + const signals: PublisherAbuseSignalDoc[] = []; + for (const signalId of signalIds) { + const signal = await ctx.db.get(signalId); + if (!signal) throw new Error("Publisher abuse signal not found"); + signals.push(signal); + } + if (signals.some((signal) => publisherAbuseSignalReviewStatus(signal) !== "open")) { + throw new Error("One or more selected signals are no longer open; refresh and try again"); + } + + const now = Date.now(); + const note = normalizePublisherAbuseSignalReviewNote(args.note); + const snoozedUntil = + args.status === "snoozed" + ? now + + clampInt(args.days ?? DEFAULT_PUBLISHER_ABUSE_SIGNAL_SNOOZE_DAYS, 1, 90) * + 24 * + 60 * + 60 * + 1000 + : undefined; + for (const signal of signals) { + await setPublisherAbuseSignalReviewStatusWithActor(ctx, { + signal, + status: args.status, + actorUserId: user._id, + note, + snoozedUntil, + now, + }); + } + + return { ok: true, status: args.status, updated: signals.length }; + }, +}); + export const reopenPublisherAbuseSignal = mutation({ args: { signalId: v.id("publisherAbuseSignals"), @@ -844,6 +899,12 @@ async function setPublisherAbuseSignalReviewStatusWithActor( freshInstallsSinceSnooze: args.status === "snoozed" ? 0 : undefined, snoozeCount: args.status === "snoozed" ? (args.signal.snoozeCount ?? 0) + 1 : args.signal.snoozeCount, + notificationBaselineDownloads: args.notify + ? args.signal.allTimeDownloads + : args.signal.notificationBaselineDownloads, + notificationBaselineInstalls: args.notify + ? args.signal.allTimeInstalls + : args.signal.notificationBaselineInstalls, needsNotification: args.notify === true, lastChangedAt: args.notify ? args.now : args.signal.lastChangedAt, notificationClaimedAt: undefined, @@ -3527,8 +3588,18 @@ async function upsertPublisherAbuseSignal( downloads: freshDownloadsSinceSnooze, installs: freshInstallsSinceSnooze, }); + const notificationBaselineDownloads = + signal.notificationBaselineDownloads ?? signal.allTimeDownloads; + const notificationBaselineInstalls = + signal.notificationBaselineInstalls ?? signal.allTimeInstalls; + const materiallyStrongerEvidence = + previousStatus === "open" && + freshEvidenceCrossesRepeatThreshold(args.signalType, { + downloads: Math.max(0, snapshot.allTimeDownloads - notificationBaselineDownloads), + installs: Math.max(0, snapshot.allTimeInstalls - notificationBaselineInstalls), + }); const nextStatus = recurringAfterSnooze ? "open" : previousStatus; - const shouldNotify = nextStatus === "open"; + const shouldNotify = recurringAfterSnooze || materiallyStrongerEvidence; await ctx.db.patch(signal._id, { ...snapshot, reviewStatus: nextStatus, @@ -3548,6 +3619,12 @@ async function upsertPublisherAbuseSignal( recurrenceCount: recurringAfterSnooze ? (signal.recurrenceCount ?? 0) + 1 : signal.recurrenceCount, + notificationBaselineDownloads: shouldNotify + ? snapshot.allTimeDownloads + : notificationBaselineDownloads, + notificationBaselineInstalls: shouldNotify + ? snapshot.allTimeInstalls + : notificationBaselineInstalls, lastSeenAt: args.now, seenCount: signal.seenCount + 1, lastChangedAt: shouldNotify ? args.now : signal.lastChangedAt, @@ -3564,6 +3641,8 @@ async function upsertPublisherAbuseSignal( lastSeenAt: args.now, seenCount: 1, reviewStatus: "open", + notificationBaselineDownloads: snapshot.allTimeDownloads, + notificationBaselineInstalls: snapshot.allTimeInstalls, lastChangedAt: args.now, needsNotification: true, }); diff --git a/convex/schema.ts b/convex/schema.ts index 8780fd0b..41a75d8b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3105,6 +3105,8 @@ const publisherAbuseSignals = defineTable({ freshInstallsSinceSnooze: v.optional(v.number()), snoozeCount: v.optional(v.number()), recurrenceCount: v.optional(v.number()), + notificationBaselineDownloads: v.optional(v.number()), + notificationBaselineInstalls: v.optional(v.number()), reviewedByUserId: v.optional(v.id("users")), reviewedAt: v.optional(v.number()), reviewNote: v.optional(v.string()), diff --git a/specs/security-moderation.md b/specs/security-moderation.md index a85a99d0..594b8b3f 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -120,11 +120,18 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic the lower repeat threshold: at least 1,500 downloads with at most 5 installs for flat-install volume, or at least 500 downloads and 50 installs at a 10% install/download ratio. Reopened repeat signals are elevated to high severity. + Staff may snooze or dismiss up to 50 selected open signals in one atomic + action. Bulk review must apply the same evidence checkpoint and write the same + per-signal review event as the corresponding single-signal action. - Hermit owns Discord notification delivery for publisher abuse Signals. ClawHub queues Hermit digests only for changed open signals: newly archived - signals, repeated open signals with a higher seen count, manual reopens, and - expired snoozes with qualifying fresh evidence. Active snoozed or dismissed - signals must update their metric snapshot without notifying Hermit. + signals, manual reopens, expired snoozes with qualifying fresh evidence, and + open signals whose evidence has materially increased since the previous + notification. A higher seen count alone is not a change. Material increases + use the same lower repeat thresholds as post-snooze recurrence, and the + notification checkpoint advances only when a notification is queued so + smaller changes accumulate across scans. Active snoozed or dismissed signals + must update their metric snapshot without notifying Hermit. - Aggregate publisher spam-abuse labels start at the 200-skill pivot. Below that pivot, publishers can contribute to the population baseline, but they cannot receive aggregate spam reason codes or be nominated by this score path. diff --git a/src/routes/-management.test.tsx b/src/routes/-management.test.tsx index df1cfb4c..9da771c8 100644 --- a/src/routes/-management.test.tsx +++ b/src/routes/-management.test.tsx @@ -715,6 +715,129 @@ describe("Management", () => { }); }); + it("bulk snoozes and dismisses selected open publisher abuse signals", async () => { + searchState = { view: "abuse", tab: "signals" }; + const reviewSignalsBatch = vi.fn(async () => ({ ok: true, status: "snoozed", updated: 2 })); + const firstSignal = makePublisherAbuseSignal(); + const secondSignal = makePublisherAbuseSignal({ + _id: "publisherAbuseSignals:sustained", + signalType: "sustained_downloads_flat_installs", + skillId: "skills:sustained", + skillSlug: "sustained-skill", + skillDisplayName: "Sustained Skill", + }); + useMutationMock.mockImplementation((mutation) => { + if (getFunctionName(mutation) === "publisherAbuse:reviewPublisherAbuseSignalsBatch") { + return reviewSignalsBatch; + } + return vi.fn(async () => ({ ok: true })); + }); + useQueryMock.mockImplementation((query, args) => { + if (args === "skip") return undefined; + const name = getFunctionName(query); + if (name === "skills:listRecentVersions") return []; + if (name === "skills:listReportedSkills") return []; + if (name === "skills:listDuplicateCandidates") return []; + if (name === "publisherAbuse:listReviewDashboard") { + return { + latestRun: null, + pendingItems: [], + pendingPotentialBanCandidateItems: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 2, + signalCountHasMore: false, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => ({ + results: + getFunctionName(query) === "publisherAbuse:listSignalsPage" && args !== "skip" + ? [firstSignal, secondSignal] + : [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + })); + + render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select Ratio Skill" })); + expect(screen.getByText("1 selected")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Snooze 1 signal" })); + fireEvent.click(screen.getAllByRole("button", { name: "Snooze 1 signal" }).at(-1)!); + await waitFor(() => { + expect(reviewSignalsBatch).toHaveBeenCalledWith({ + signalIds: ["publisherAbuseSignals:ratio"], + status: "snoozed", + note: undefined, + days: 14, + }); + }); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select Sustained Skill" })); + expect(screen.getByText("2 selected")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Dismiss 2 signals" })); + fireEvent.click(screen.getAllByRole("button", { name: "Dismiss 2 signals" }).at(-1)!); + await waitFor(() => { + expect(reviewSignalsBatch).toHaveBeenCalledWith({ + signalIds: ["publisherAbuseSignals:ratio", "publisherAbuseSignals:sustained"], + status: "dismissed", + note: undefined, + }); + }); + }); + + it("caps bulk signal selection at the backend batch limit", () => { + searchState = { view: "abuse", tab: "signals" }; + const signalResults = Array.from({ length: 51 }, (_, index) => + makePublisherAbuseSignal({ + _id: `publisherAbuseSignals:bulk-${index}`, + skillId: `skills:bulk-${index}`, + skillSlug: `bulk-${index}`, + skillDisplayName: `Bulk Skill ${index}`, + }), + ); + useQueryMock.mockImplementation((query, args) => { + if (args === "skip") return undefined; + const name = getFunctionName(query); + if (name === "skills:listRecentVersions") return []; + if (name === "skills:listReportedSkills") return []; + if (name === "skills:listDuplicateCandidates") return []; + if (name === "publisherAbuse:listReviewDashboard") { + return { + latestRun: null, + pendingItems: [], + pendingPotentialBanCandidateItems: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: signalResults.length, + signalCountHasMore: false, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => ({ + results: + getFunctionName(query) === "publisherAbuse:listSignalsPage" && args !== "skip" + ? signalResults + : [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + })); + + render(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select all loaded signals" })); + expect(screen.getByText("50 selected · 50 maximum")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Dismiss 50 signals" })).toBeTruthy(); + expect( + (screen.getByRole("checkbox", { name: "Select Bulk Skill 50" }) as HTMLInputElement).disabled, + ).toBe(true); + }); + it("updates publisher abuse tab badges when live counts decrease", () => { const firstItem = makePublisherAbuseItem({ id: "1", handle: "first-pub" }); const secondItem = makePublisherAbuseItem({ id: "2", handle: "second-pub" }); diff --git a/src/routes/-management/AbusePage.tsx b/src/routes/-management/AbusePage.tsx index 786c57f5..589cf66b 100644 --- a/src/routes/-management/AbusePage.tsx +++ b/src/routes/-management/AbusePage.tsx @@ -42,6 +42,8 @@ import { USER_BAN_REASON_MAX_LENGTH, } from "./managementShared"; +const MAX_BULK_SIGNAL_SELECTION = 50; + export function AbusePage({ admin, autobanSetting, @@ -66,12 +68,14 @@ export function AbusePage({ onChangeTab, onClose, onDismissSignal, + onDismissSignals, onMarkReviewed, onLoadMore, onRefresh, onReopenSignal, onSelect, onSnoozeSignal, + onSnoozeSignals, onToggleAutoban, }: { admin: boolean; @@ -103,17 +107,22 @@ export function AbusePage({ onChangeTab: (value: PublisherAbuseTab) => void; onClose: () => void; onDismissSignal: (item: PublisherAbuseSignalEntry) => void; + onDismissSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void; onMarkReviewed: (item: PublisherAbuseReviewItem) => void; onLoadMore: () => void; onRefresh: () => void; onReopenSignal: (item: PublisherAbuseSignalEntry) => void; onSelect: (value: Id<"publisherAbuseReviewNominations">) => void; onSnoozeSignal: (item: PublisherAbuseSignalEntry) => void; + onSnoozeSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void; onToggleAutoban: () => void; }) { const [selectedSignalItem, setSelectedSignalItem] = useState( null, ); + const [selectedSignalIds, setSelectedSignalIds] = useState>>( + new Set(), + ); const selectedSignalId = selectedSignalItem?.signal._id ?? null; useEffect(() => { if (!selectedSignalId) return; @@ -130,6 +139,16 @@ export function AbusePage({ setSelectedSignalItem(null); } }, [selectedSignalId, signalItems, signalPageStatus, tab]); + useEffect(() => { + setSelectedSignalIds(new Set()); + }, [signalStatus, tab]); + useEffect(() => { + const visibleSignalIds = new Set(signalItems.map((item) => item.signal._id)); + setSelectedSignalIds((current) => { + const next = new Set([...current].filter((signalId) => visibleSignalIds.has(signalId))); + return next.size === current.size ? current : next; + }); + }, [signalItems]); const latestRun = dashboard?.latestRun ?? null; const latestSignalRun = dashboard?.latestSignalRun ?? null; const displayedRun = tab === "signals" ? latestSignalRun : latestRun; @@ -455,9 +474,32 @@ export function AbusePage({ items={signalItems} loaded={signalsLoaded} selectedSignalId={selectedSignalItem?.signal._id ?? null} + selectedSignalIds={selectedSignalIds} status={signalStatus} searchActive={search.trim().length > 0} + onClearSignalSelection={() => setSelectedSignalIds(new Set())} + onDismissSignals={onDismissSignals} onSelectSignal={setSelectedSignalItem} + onSnoozeSignals={onSnoozeSignals} + onToggleAllSignals={(checked) => { + setSelectedSignalIds( + checked + ? new Set( + signalItems + .slice(0, MAX_BULK_SIGNAL_SELECTION) + .map((item) => item.signal._id), + ) + : new Set(), + ); + }} + onToggleSignal={(signalId, checked) => { + setSelectedSignalIds((current) => { + const next = new Set(current); + if (checked && next.size < MAX_BULK_SIGNAL_SELECTION) next.add(signalId); + else next.delete(signalId); + return next; + }); + }} /> ) : (
@@ -881,112 +923,210 @@ function PublisherAbuseSignalsTable({ items, loaded, selectedSignalId, + selectedSignalIds, status, searchActive, + onClearSignalSelection, + onDismissSignals, onSelectSignal, + onSnoozeSignals, + onToggleAllSignals, + onToggleSignal, }: { canLoadMore: boolean; items: PublisherAbuseSignalEntry[]; loaded: boolean; selectedSignalId: Id<"publisherAbuseSignals"> | null; + selectedSignalIds: Set>; status: PublisherAbuseSignalStatus; searchActive: boolean; + onClearSignalSelection: () => void; + onDismissSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void; onSelectSignal: (item: PublisherAbuseSignalEntry) => void; + onSnoozeSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void; + onToggleAllSignals: (checked: boolean) => void; + onToggleSignal: (signalId: Id<"publisherAbuseSignals">, checked: boolean) => void; }) { const emptyState = publisherAbuseSignalEmptyState(searchActive, canLoadMore, status); + const bulkSelectionEnabled = status === "open" && loaded && items.length > 0; + const selectedIds = [...selectedSignalIds]; + const selectedCount = selectedIds.length; + const selectionAtLimit = selectedCount >= MAX_BULK_SIGNAL_SELECTION; + const selectableItems = items.slice(0, MAX_BULK_SIGNAL_SELECTION); + const allLoadedSelected = + bulkSelectionEnabled && + selectableItems.length > 0 && + selectableItems.every((item) => selectedSignalIds.has(item.signal._id)); return ( -
- - - - - - - - - - - - {!loaded ? ( - - ) : items.length === 0 ? ( - - - - ) : ( - items.map((item) => { - const selected = item.signal._id === selectedSignalId; - const recurrenceCount = item.signal.recurrenceCount ?? 0; - return ( - onSelectSignal(item)} - > - - - - + {bulkSelectionEnabled ? ( +
+ + {formatWholeNumber(selectedCount)} selected + {selectionAtLimit ? ` · ${MAX_BULK_SIGNAL_SELECTION} maximum` : null} + +
+ + + {selectedCount > 0 ? ( + + ) : null} +
+
+ ) : null} +
+
SeveritySignalSubjectEvidenceLast seen
- {emptyState.title} - {emptyState.body} -
- - {formatPublisherAbuseSignalSeverity(item.signal.signalType, recurrenceCount)} - - - - {recurrenceCount > 0 ? ( -
Repeat after snooze
- ) : item.signal.snoozedUntil ? ( -
- {formatPublisherAbuseSnoozeState(item.signal.snoozedUntil)} -
- ) : null} -
-
- {item.signal.skillDisplayName} - - @{item.signal.handleSnapshot} / {item.signal.skillSlug} - -
-
+ + + {bulkSelectionEnabled ? ( + - - ); - }) - )} - -
+ onToggleAllSignals(event.target.checked)} /> - {formatShortTimestamp(item.signal.lastSeenAt)}
-
+ + ) : null} + Severity + Signal + Subject + Evidence + Last seen + + + + {!loaded ? ( + + ) : items.length === 0 ? ( + + + {emptyState.title} + {emptyState.body} + + + ) : ( + items.map((item) => { + const selected = item.signal._id === selectedSignalId; + const bulkSelected = selectedSignalIds.has(item.signal._id); + const recurrenceCount = item.signal.recurrenceCount ?? 0; + return ( + onSelectSignal(item)} + > + {bulkSelectionEnabled ? ( + + event.stopPropagation()} + onChange={(event) => + onToggleSignal(item.signal._id, event.target.checked) + } + /> + + ) : null} + + + {formatPublisherAbuseSignalSeverity( + item.signal.signalType, + recurrenceCount, + )} + + + + + {recurrenceCount > 0 ? ( +
Repeat after snooze
+ ) : item.signal.snoozedUntil ? ( +
+ {formatPublisherAbuseSnoozeState(item.signal.snoozedUntil)} +
+ ) : null} + + +
+ {item.signal.skillDisplayName} + + @{item.signal.handleSnapshot} / {item.signal.skillSlug} + +
+ + + {formatShortTimestamp(item.signal.lastSeenAt)} + + ); + }) + )} + + +
+ ); } +function bulkSignalActionLabel(action: "Snooze" | "Dismiss", count: number) { + if (count <= 0) return `${action} selected`; + return `${action} ${formatWholeNumber(count)} ${count === 1 ? "signal" : "signals"}`; +} + function PublisherAbuseSignalInspector({ item, onDismissSignal, diff --git a/src/routes/management.tsx b/src/routes/management.tsx index 697a723c..862a2d3a 100644 --- a/src/routes/management.tsx +++ b/src/routes/management.tsx @@ -285,6 +285,9 @@ export function Management() { ); const snoozePublisherAbuseSignal = useMutation(api.publisherAbuse.snoozePublisherAbuseSignal); const dismissPublisherAbuseSignal = useMutation(api.publisherAbuse.dismissPublisherAbuseSignal); + const reviewPublisherAbuseSignalsBatch = useMutation( + api.publisherAbuse.reviewPublisherAbuseSignalsBatch, + ); const reopenPublisherAbuseSignal = useMutation(api.publisherAbuse.reopenPublisherAbuseSignal); const startPublisherAbuseScoreRun = useAction(api.publisherAbuse.startPublisherAbuseScoreRun); const startPublisherAbuseSignalScan = useAction( @@ -724,6 +727,52 @@ export function Management() { }); }; + const requestSnoozePublisherAbuseSignals = (signalIds: Id<"publisherAbuseSignals">[]) => { + const count = signalIds.length; + if (count === 0) return; + const label = `${count} ${count === 1 ? "signal" : "signals"}`; + setConfirmRequest({ + title: `Snooze ${label}?`, + body: "Hides the selected signals for 14 days and acknowledges the evidence shown now. Each signal reopens only if fresh activity crosses the repeat threshold.", + confirmLabel: `Snooze ${label}`, + reason: { + label: "Note (optional)", + placeholder: "Why are you snoozing these signals?", + }, + onConfirm: (note) => { + void reviewPublisherAbuseSignalsBatch({ + signalIds, + status: "snoozed", + note, + days: 14, + }) + .then((result) => toast.success(`${result.updated} signals snoozed.`)) + .catch((error) => toast.error(formatMutationError(error))); + }, + }); + }; + + const requestDismissPublisherAbuseSignals = (signalIds: Id<"publisherAbuseSignals">[]) => { + const count = signalIds.length; + if (count === 0) return; + const label = `${count} ${count === 1 ? "signal" : "signals"}`; + setConfirmRequest({ + title: `Dismiss ${label}?`, + body: "Archives the selected signals and removes them from the Open queue. They will not notify Hermit unless a moderator reopens them.", + confirmLabel: `Dismiss ${label}`, + destructive: true, + reason: { + label: "Note (optional)", + placeholder: "Why are you dismissing these signals?", + }, + onConfirm: (note) => { + void reviewPublisherAbuseSignalsBatch({ signalIds, status: "dismissed", note }) + .then((result) => toast.success(`${result.updated} signals dismissed.`)) + .catch((error) => toast.error(formatMutationError(error))); + }, + }); + }; + const requestReopenPublisherAbuseSignal = (item: PublisherAbuseSignalEntry) => { setConfirmRequest({ title: `Reopen ${item.signal.skillDisplayName}?`, @@ -863,6 +912,7 @@ export function Management() { }} onToggleAutoban={requestTogglePublisherAbuseAutoban} onDismissSignal={requestDismissPublisherAbuseSignal} + onDismissSignals={requestDismissPublisherAbuseSignals} onMarkReviewed={requestMarkPublisherAbuseNominationReviewed} onLoadMore={() => { if (publisherAbuseTab === "signals") { @@ -912,6 +962,7 @@ export function Management() { setSelectedPublisherAbuseNominationId(nominationId); }} onSnoozeSignal={requestSnoozePublisherAbuseSignal} + onSnoozeSignals={requestSnoozePublisherAbuseSignals} /> ) : null} diff --git a/src/styles.css b/src/styles.css index 583b56ef..03a027eb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -14584,6 +14584,34 @@ code { color: var(--ink); } +.pa-signal-bulk-bar { + display: flex; + min-height: 42px; + margin: 0 calc(-1 * var(--space-4)); + padding: 6px var(--space-4); + align-items: center; + justify-content: space-between; + gap: var(--space-3); + border-top: 1px solid var(--oc-border-subtle); + border-bottom: 1px solid var(--oc-border-subtle); + background: color-mix(in srgb, var(--oc-bg-surface) 78%, var(--oc-bg-page)); +} + +.pa-signal-bulk-count { + color: var(--oc-text-secondary); + font-size: var(--fs-xs); + font-weight: 650; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.pa-signal-bulk-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + .pa-table-wrap { min-width: 0; margin: 0 calc(-1 * var(--space-4)); @@ -14671,6 +14699,46 @@ code { background: var(--hover-bg); } +.pa-signals-table tbody tr.is-bulk-selected { + background: color-mix(in srgb, var(--oc-accent-primary) 9%, transparent); +} + +.pa-signals-table .pa-signal-select-cell { + width: 42px; + padding-right: 0; + text-align: center; +} + +.pa-signal-select-cell input[type="checkbox"] { + appearance: none; + width: 16px; + height: 16px; + margin: 0; + border: 1px solid var(--oc-border-subtle); + border-radius: var(--oc-radius-inset); + background-color: var(--oc-bg-surface); + cursor: pointer; +} + +.pa-signal-select-cell input[type="checkbox"]:checked { + border-color: var(--oc-accent-primary); + background-color: var(--oc-accent-primary); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m3.5 8 3 3 6-6' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E"); + background-position: center; + background-repeat: no-repeat; + background-size: 14px; +} + +.pa-signal-select-cell input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.pa-signal-select-cell input[type="checkbox"]:focus-visible { + outline: 2px solid var(--oc-focus-ring); + outline-offset: 2px; +} + .pa-num { text-align: right; font-variant-numeric: tabular-nums; @@ -15199,6 +15267,17 @@ code { grid-template-columns: 1fr; } + .pa-signal-bulk-bar { + align-items: flex-start; + flex-direction: column; + } + + .pa-signal-bulk-actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + .pa-score > div { border-left: 0; border-top: 1px solid var(--line);