Dedupe abuse signal alerts and add bulk review (#3214)

* fix: dedupe abuse alerts and add bulk review

* fix: use white bulk selection checks
This commit is contained in:
Jesse Merhi
2026-07-22 13:25:54 +10:00
committed by GitHub
parent f9713e81cd
commit 7ef2b15cfb
8 changed files with 735 additions and 94 deletions
+162 -2
View File
@@ -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<Record<string, unknown>> = [];
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 () => {
+80 -1
View File
@@ -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,
});
+2
View File
@@ -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()),
+10 -3
View File
@@ -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.
+123
View File
@@ -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(<Management />);
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(<Management />);
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" });
+228 -88
View File
@@ -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<PublisherAbuseSignalEntry | null>(
null,
);
const [selectedSignalIds, setSelectedSignalIds] = useState<Set<Id<"publisherAbuseSignals">>>(
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;
});
}}
/>
) : (
<div className="pa-table-wrap">
@@ -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<Id<"publisherAbuseSignals">>;
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 (
<div className="pa-table-wrap">
<table className="pa-table pa-signals-table">
<thead>
<tr>
<th>Severity</th>
<th>Signal</th>
<th>Subject</th>
<th className="pa-num">Evidence</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{!loaded ? (
<PublisherAbuseTableSkeletonRows columns={5} label="Loading publisher abuse signals" />
) : items.length === 0 ? (
<tr className="pa-empty-row">
<td colSpan={5}>
<strong>{emptyState.title}</strong>
{emptyState.body}
</td>
</tr>
) : (
items.map((item) => {
const selected = item.signal._id === selectedSignalId;
const recurrenceCount = item.signal.recurrenceCount ?? 0;
return (
<tr
key={item.signal._id}
className={selected ? "is-selected" : undefined}
onClick={() => onSelectSignal(item)}
>
<td>
<Badge
variant={publisherAbuseSignalSeverityVariant(
item.signal.signalType,
recurrenceCount,
)}
size="sm"
>
{formatPublisherAbuseSignalSeverity(item.signal.signalType, recurrenceCount)}
</Badge>
</td>
<td>
<button
type="button"
className="pa-signal-summary pa-row-button"
aria-label={`Open details for ${item.signal.skillDisplayName}`}
onClick={(event) => {
event.stopPropagation();
onSelectSignal(item);
}}
>
<strong className="pa-signal-name">
{formatPublisherAbuseSignalType(item.signal.signalType)}
</strong>
<span>
{formatPublisherAbuseSignalStatus(signalReviewStatus(item))}
{" · "}Seen {formatWholeNumber(item.signal.seenCount)}x
</span>
</button>
{recurrenceCount > 0 ? (
<div className="pa-signal-repeat is-recurring">Repeat after snooze</div>
) : item.signal.snoozedUntil ? (
<div className="pa-signal-repeat">
{formatPublisherAbuseSnoozeState(item.signal.snoozedUntil)}
</div>
) : null}
</td>
<td>
<div className="pa-signal-subject">
<strong>{item.signal.skillDisplayName}</strong>
<span>
@{item.signal.handleSnapshot} / {item.signal.skillSlug}
</span>
</div>
</td>
<PublisherAbuseSignalRatioCell
downloads={item.signal.recent30Downloads}
installs={item.signal.recent30Installs}
ratio={item.signal.recent30InstallDownloadRatio}
<>
{bulkSelectionEnabled ? (
<div className="pa-signal-bulk-bar" aria-label="Bulk signal actions">
<span className="pa-signal-bulk-count" aria-live="polite">
{formatWholeNumber(selectedCount)} selected
{selectionAtLimit ? ` · ${MAX_BULK_SIGNAL_SELECTION} maximum` : null}
</span>
<div className="pa-signal-bulk-actions">
<Button
type="button"
variant="outline"
size="xs"
disabled={selectedCount === 0}
onClick={() => onSnoozeSignals(selectedIds)}
>
<Clock3 size={14} aria-hidden="true" />
{bulkSignalActionLabel("Snooze", selectedCount)}
</Button>
<Button
type="button"
variant="outline"
size="xs"
disabled={selectedCount === 0}
onClick={() => onDismissSignals(selectedIds)}
>
<XCircle size={14} aria-hidden="true" />
{bulkSignalActionLabel("Dismiss", selectedCount)}
</Button>
{selectedCount > 0 ? (
<Button type="button" variant="ghost" size="xs" onClick={onClearSignalSelection}>
Clear
</Button>
) : null}
</div>
</div>
) : null}
<div className="pa-table-wrap">
<table className="pa-table pa-signals-table">
<thead>
<tr>
{bulkSelectionEnabled ? (
<th className="pa-signal-select-cell">
<input
type="checkbox"
aria-label="Select all loaded signals"
checked={allLoadedSelected}
onChange={(event) => onToggleAllSignals(event.target.checked)}
/>
<td className="pa-muted">{formatShortTimestamp(item.signal.lastSeenAt)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</th>
) : null}
<th>Severity</th>
<th>Signal</th>
<th>Subject</th>
<th className="pa-num">Evidence</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{!loaded ? (
<PublisherAbuseTableSkeletonRows
columns={5}
label="Loading publisher abuse signals"
/>
) : items.length === 0 ? (
<tr className="pa-empty-row">
<td colSpan={bulkSelectionEnabled ? 6 : 5}>
<strong>{emptyState.title}</strong>
{emptyState.body}
</td>
</tr>
) : (
items.map((item) => {
const selected = item.signal._id === selectedSignalId;
const bulkSelected = selectedSignalIds.has(item.signal._id);
const recurrenceCount = item.signal.recurrenceCount ?? 0;
return (
<tr
key={item.signal._id}
className={
[selected ? "is-selected" : "", bulkSelected ? "is-bulk-selected" : ""]
.filter(Boolean)
.join(" ") || undefined
}
onClick={() => onSelectSignal(item)}
>
{bulkSelectionEnabled ? (
<td className="pa-signal-select-cell">
<input
type="checkbox"
aria-label={`Select ${item.signal.skillDisplayName}`}
checked={bulkSelected}
disabled={selectionAtLimit && !bulkSelected}
onClick={(event) => event.stopPropagation()}
onChange={(event) =>
onToggleSignal(item.signal._id, event.target.checked)
}
/>
</td>
) : null}
<td>
<Badge
variant={publisherAbuseSignalSeverityVariant(
item.signal.signalType,
recurrenceCount,
)}
size="sm"
>
{formatPublisherAbuseSignalSeverity(
item.signal.signalType,
recurrenceCount,
)}
</Badge>
</td>
<td>
<button
type="button"
className="pa-signal-summary pa-row-button"
aria-label={`Open details for ${item.signal.skillDisplayName}`}
onClick={(event) => {
event.stopPropagation();
onSelectSignal(item);
}}
>
<strong className="pa-signal-name">
{formatPublisherAbuseSignalType(item.signal.signalType)}
</strong>
<span>
{formatPublisherAbuseSignalStatus(signalReviewStatus(item))}
{" · "}Seen {formatWholeNumber(item.signal.seenCount)}x
</span>
</button>
{recurrenceCount > 0 ? (
<div className="pa-signal-repeat is-recurring">Repeat after snooze</div>
) : item.signal.snoozedUntil ? (
<div className="pa-signal-repeat">
{formatPublisherAbuseSnoozeState(item.signal.snoozedUntil)}
</div>
) : null}
</td>
<td>
<div className="pa-signal-subject">
<strong>{item.signal.skillDisplayName}</strong>
<span>
@{item.signal.handleSnapshot} / {item.signal.skillSlug}
</span>
</div>
</td>
<PublisherAbuseSignalRatioCell
downloads={item.signal.recent30Downloads}
installs={item.signal.recent30Installs}
ratio={item.signal.recent30InstallDownloadRatio}
/>
<td className="pa-muted">{formatShortTimestamp(item.signal.lastSeenAt)}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</>
);
}
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,
+51
View File
@@ -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}
+79
View File
@@ -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);