diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index c75d1f8..dc447dd 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -1469,6 +1469,27 @@ export type GQLIntegrationNoInputCredentialsError = GQLError & { readonly type: ReadonlyArray; }; +export type GQLInvalidateReportsFromReporterInput = { + /** + * Scopes the sweep to a single MRT job. When omitted, every pending job + * in the caller's org is scanned. + */ + readonly jobId?: InputMaybe; + readonly reason?: InputMaybe; + readonly reporter: GQLReporterIdInput; +}; + +export type GQLInvalidateReportsFromReporterSuccessResponse = { + readonly __typename: 'InvalidateReportsFromReporterSuccessResponse'; + readonly jobsDeleted: Scalars['Int']['output']; + readonly jobsScanned: Scalars['Int']['output']; + readonly jobsScrubbed: Scalars['Int']['output']; + readonly queuesScanned: Scalars['Int']['output']; + readonly reportsRemoved: Scalars['Int']['output']; + /** True when a queue exceeded the per-queue scan cap, so the sweep was partial. */ + readonly truncated: Scalars['Boolean']['output']; +}; + export type GQLInviteUserInput = { readonly email: Scalars['String']['input']; readonly role: GQLUserRole; @@ -2447,6 +2468,14 @@ export type GQLMutation = { readonly deleteUser?: Maybe; readonly dequeueManualReviewJob?: Maybe; readonly generatePasswordResetToken?: Maybe; + /** + * Strips every entry sent by the given reporter from the report history of + * every pending MRT job in the caller's org. If a job's history becomes + * empty and it was originally enqueued from a user report, the job itself + * is removed. Intentionally non-persistent: future reports from the same + * reporter are NOT blocked. See issue #404. + */ + readonly invalidateReportsFromReporter: GQLInvalidateReportsFromReporterSuccessResponse; readonly inviteUser?: Maybe; readonly logSkip: Scalars['Boolean']['output']; readonly login: GQLLoginResponse; @@ -2649,6 +2678,10 @@ export type GQLMutationGeneratePasswordResetTokenArgs = { userId: Scalars['ID']['input']; }; +export type GQLMutationInvalidateReportsFromReporterArgs = { + input: GQLInvalidateReportsFromReporterInput; +}; + export type GQLMutationInviteUserArgs = { input: GQLInviteUserInput; }; @@ -12034,6 +12067,23 @@ export type GQLSetModeratorSafetySettingsMutation = { } | null; }; +export type GQLInvalidateReportsFromReporterMutationVariables = Exact<{ + input: GQLInvalidateReportsFromReporterInput; +}>; + +export type GQLInvalidateReportsFromReporterMutation = { + readonly __typename: 'Mutation'; + readonly invalidateReportsFromReporter: { + readonly __typename: 'InvalidateReportsFromReporterSuccessResponse'; + readonly queuesScanned: number; + readonly jobsScanned: number; + readonly jobsScrubbed: number; + readonly jobsDeleted: number; + readonly reportsRemoved: number; + readonly truncated: boolean; + }; +}; + export type GQLManualReviewJobInfoQueryVariables = Exact<{ jobIds?: InputMaybe< ReadonlyArray | Scalars['ID']['input'] @@ -33947,6 +33997,65 @@ export type GQLSetModeratorSafetySettingsMutationOptions = GQLSetModeratorSafetySettingsMutation, GQLSetModeratorSafetySettingsMutationVariables >; +export const GQLInvalidateReportsFromReporterDocument = gql` + mutation InvalidateReportsFromReporter( + $input: InvalidateReportsFromReporterInput! + ) { + invalidateReportsFromReporter(input: $input) { + queuesScanned + jobsScanned + jobsScrubbed + jobsDeleted + reportsRemoved + truncated + } + } +`; +export type GQLInvalidateReportsFromReporterMutationFn = + Apollo.MutationFunction< + GQLInvalidateReportsFromReporterMutation, + GQLInvalidateReportsFromReporterMutationVariables + >; + +/** + * __useGQLInvalidateReportsFromReporterMutation__ + * + * To run a mutation, you first call `useGQLInvalidateReportsFromReporterMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGQLInvalidateReportsFromReporterMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [gqlInvalidateReportsFromReporterMutation, { data, loading, error }] = useGQLInvalidateReportsFromReporterMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGQLInvalidateReportsFromReporterMutation( + baseOptions?: Apollo.MutationHookOptions< + GQLInvalidateReportsFromReporterMutation, + GQLInvalidateReportsFromReporterMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + GQLInvalidateReportsFromReporterMutation, + GQLInvalidateReportsFromReporterMutationVariables + >(GQLInvalidateReportsFromReporterDocument, options); +} +export type GQLInvalidateReportsFromReporterMutationHookResult = ReturnType< + typeof useGQLInvalidateReportsFromReporterMutation +>; +export type GQLInvalidateReportsFromReporterMutationResult = + Apollo.MutationResult; +export type GQLInvalidateReportsFromReporterMutationOptions = + Apollo.BaseMutationOptions< + GQLInvalidateReportsFromReporterMutation, + GQLInvalidateReportsFromReporterMutationVariables + >; export const GQLManualReviewJobInfoDocument = gql` query ManualReviewJobInfo($jobIds: [ID!]) { myOrg { @@ -34014,6 +34123,7 @@ export const GQLManualReviewJobInfoDocument = gql` } me { id + permissions reviewableQueues { id name @@ -44167,6 +44277,7 @@ export const namedOperations = { AddFavoriteMRTQueue: 'AddFavoriteMRTQueue', RemoveFavoriteMRTQueue: 'RemoveFavoriteMRTQueue', SetModeratorSafetySettings: 'SetModeratorSafetySettings', + InvalidateReportsFromReporter: 'InvalidateReportsFromReporter', DequeueManualReviewJob: 'DequeueManualReviewJob', SubmitManualReviewDecision: 'SubmitManualReviewDecision', LogSkip: 'LogSkip', diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/InvalidateReportsButton.test.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/InvalidateReportsButton.test.tsx new file mode 100644 index 0000000..2660154 --- /dev/null +++ b/client/src/webpages/dashboard/mrt/manual_review_job/InvalidateReportsButton.test.tsx @@ -0,0 +1,249 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import '@testing-library/jest-dom/extend-expect'; + +import { + GQLInvalidateReportsFromReporterDocument, + type GQLInvalidateReportsFromReporterMutation, + type GQLInvalidateReportsFromReporterMutationVariables, +} from '@/graphql/generated'; + +import InvalidateReportsButton from './InvalidateReportsButton'; + +const reporter = { id: 'bad_reporter', typeId: 'user_type' }; + +function successData( + overrides: Partial< + Omit< + GQLInvalidateReportsFromReporterMutation['invalidateReportsFromReporter'], + '__typename' + > + > = {}, +): GQLInvalidateReportsFromReporterMutation { + return { + __typename: 'Mutation', + invalidateReportsFromReporter: { + __typename: 'InvalidateReportsFromReporterSuccessResponse', + queuesScanned: 1, + jobsScanned: 1, + jobsScrubbed: 1, + jobsDeleted: 0, + reportsRemoved: 1, + truncated: false, + ...overrides, + }, + }; +} + +describe('InvalidateReportsButton', () => { + it('renders the trigger button', () => { + render( + + + , + ); + expect( + screen.getByRole('button', { name: /invalidate.*reports/i }), + ).toBeInTheDocument(); + }); + + it('shows the org-wide copy and no scope checkbox when no jobId is provided', () => { + render( + + + , + ); + fireEvent.click( + screen.getByRole('button', { name: /invalidate.*reports/i }), + ); + expect(screen.getByText(/Bad Actor/)).toBeInTheDocument(); + expect( + screen.getByText(/across every pending review job in your org/i), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(/Also invalidate this reporter's reports/i), + ).not.toBeInTheDocument(); + }); + + it('shows the single-job copy and a scope checkbox when jobId is provided', () => { + render( + + + , + ); + fireEvent.click( + screen.getByRole('button', { name: /^invalidate reports$/i }), + ); + expect(screen.getByText(/on this review job/i)).toBeInTheDocument(); + expect( + screen.getByText(/Also invalidate this reporter's reports/i), + ).toBeInTheDocument(); + }); + + it('sends jobId in variables when scoped to the current job (default)', async () => { + const variables: GQLInvalidateReportsFromReporterMutationVariables = { + input: { reporter, jobId: 'job_1' }, + }; + let calledVariables: typeof variables | undefined; + const mocks = [ + { + request: { query: GQLInvalidateReportsFromReporterDocument, variables }, + result: () => { + calledVariables = variables; + return { data: successData() }; + }, + }, + ]; + + render( + + + , + ); + fireEvent.click( + screen.getByRole('button', { name: /^invalidate reports$/i }), + ); + fireEvent.click(screen.getByRole('button', { name: /^invalidate$/i })); + + await waitFor(() => { + expect(calledVariables).toBeDefined(); + }); + expect(calledVariables?.input.jobId).toBe('job_1'); + }); + + it('omits jobId when the reviewer expands the scope to the org', async () => { + const variables: GQLInvalidateReportsFromReporterMutationVariables = { + input: { reporter, jobId: undefined }, + }; + let calledVariables: typeof variables | undefined; + const mocks = [ + { + request: { query: GQLInvalidateReportsFromReporterDocument, variables }, + result: () => { + calledVariables = variables; + return { data: successData() }; + }, + }, + ]; + + render( + + + , + ); + fireEvent.click( + screen.getByRole('button', { name: /^invalidate reports$/i }), + ); + fireEvent.click( + screen.getByLabelText(/Also invalidate this reporter's reports/i), + ); + fireEvent.click(screen.getByRole('button', { name: /^invalidate$/i })); + + await waitFor(() => { + expect(calledVariables).toBeDefined(); + }); + expect(calledVariables?.input.jobId).toBeUndefined(); + }); + + it('awaits onInvalidated after the mutation resolves', async () => { + const variables: GQLInvalidateReportsFromReporterMutationVariables = { + input: { reporter, jobId: 'job_1' }, + }; + const mutationOrder: string[] = []; + const mocks = [ + { + request: { query: GQLInvalidateReportsFromReporterDocument, variables }, + result: () => { + mutationOrder.push('mutation'); + return { data: successData({ jobsDeleted: 1 }) }; + }, + }, + ]; + + let resolveHandler: (() => void) | undefined; + const onInvalidated = jest.fn( + async () => + new Promise((resolve) => { + mutationOrder.push('handler-start'); + resolveHandler = resolve; + }), + ); + + render( + + + , + ); + fireEvent.click( + screen.getByRole('button', { name: /^invalidate reports$/i }), + ); + fireEvent.click(screen.getByRole('button', { name: /^invalidate$/i })); + + await waitFor(() => expect(onInvalidated).toHaveBeenCalled()); + expect(mutationOrder).toEqual(['mutation', 'handler-start']); + + resolveHandler?.(); + }); + + it('sends a trimmed reason on confirm', async () => { + const variables: GQLInvalidateReportsFromReporterMutationVariables = { + input: { reporter, reason: 'mass-flagging' }, + }; + let calledVariables: typeof variables | undefined; + const mocks = [ + { + request: { query: GQLInvalidateReportsFromReporterDocument, variables }, + result: () => { + calledVariables = variables; + return { data: successData() }; + }, + }, + ]; + + render( + + + , + ); + fireEvent.click( + screen.getByRole('button', { name: /invalidate.*reports/i }), + ); + fireEvent.change(screen.getByLabelText(/reason \(optional/i), { + target: { value: ' mass-flagging ' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^invalidate$/i })); + + await waitFor(() => { + expect(calledVariables).toBeDefined(); + }); + expect(calledVariables?.input.reason).toBe('mass-flagging'); + }); +}); diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/InvalidateReportsButton.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/InvalidateReportsButton.tsx new file mode 100644 index 0000000..f286572 --- /dev/null +++ b/client/src/webpages/dashboard/mrt/manual_review_job/InvalidateReportsButton.tsx @@ -0,0 +1,240 @@ +import { Button } from '@/coop-ui/Button'; +import { Checkbox } from '@/coop-ui/Checkbox'; +import { useGQLInvalidateReportsFromReporterMutation } from '@/graphql/generated'; +import { gql } from '@apollo/client'; +import { Input, message } from 'antd'; +import { ShieldOff } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import CoopModal from '../../components/CoopModal'; + +gql` + mutation InvalidateReportsFromReporter( + $input: InvalidateReportsFromReporterInput! + ) { + invalidateReportsFromReporter(input: $input) { + queuesScanned + jobsScanned + jobsScrubbed + jobsDeleted + reportsRemoved + truncated + } + } +`; + +/** + * Action button to invalidate reports from a given `reporter`. When `jobId` + * is set the modal defaults to "this job only"; reviewers can opt in to + * sweeping every pending job in the org with a checkbox. Without `jobId` + * the action is always org-wide. Gated on EDIT_MRT_QUEUES; non-persistent (#404). + */ +export default function InvalidateReportsButton(props: { + reporter: { id: string; typeId: string }; + reporterDisplayName?: string; + jobId?: string; + /** + * Fired after the mutation resolves. The button awaits the returned + * promise so the modal spinner stays up while the parent refreshes the + * job view and advances to the next item if this job was deleted. + */ + onInvalidated?: () => Promise | void; +}) { + const { reporter, reporterDisplayName, jobId, onInvalidated } = props; + const supportsScopeChoice = jobId != null; + const [visible, setVisible] = useState(false); + const [reason, setReason] = useState(''); + const [expandToOrg, setExpandToOrg] = useState(false); + // Covers the mutation plus the parent's onInvalidated handler. + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!visible) { + setReason(''); + setExpandToOrg(false); + } + }, [visible]); + + const scopedToCurrentJob = supportsScopeChoice && !expandToOrg; + + const [invalidateReports] = useGQLInvalidateReportsFromReporterMutation({ + onError: (err) => { + // Avoid surfacing raw server error messages to the reviewer; log + // detail to the console for debugging. + // eslint-disable-next-line no-console + console.error('[InvalidateReportsButton] mutation failed', err); + message.error( + 'Could not invalidate reports. Please try again or contact support.', + ); + }, + onCompleted: (data) => { + const result = data.invalidateReportsFromReporter; + message.success( + formatSuccessMessage({ + reportsRemoved: result.reportsRemoved, + jobsDeleted: result.jobsDeleted, + scope: scopedToCurrentJob ? 'currentJob' : 'orgWide', + }), + ); + if (result.truncated) { + message.warning( + 'Some queues had more reports than could be processed in one pass. Run the action again to continue.', + ); + } + }, + }); + + const onConfirm = useCallback(async () => { + setSubmitting(true); + try { + // Await the mutation before handing off so the parent's refresh and + // advance run strictly after the server has settled. + await invalidateReports({ + variables: { + input: { + reporter, + reason: reason.trim() ? reason.trim() : undefined, + jobId: scopedToCurrentJob ? jobId : undefined, + }, + }, + }); + await onInvalidated?.(); + } catch (err) { + // Mutation errors are handled by `onError`; this catches follow-up + // failures from the parent handler. + // eslint-disable-next-line no-console + console.error( + '[InvalidateReportsButton] post-invalidate handler failed', + err, + ); + } finally { + setSubmitting(false); + setVisible(false); + } + }, [ + invalidateReports, + reporter, + reason, + scopedToCurrentJob, + jobId, + onInvalidated, + ]); + + const displayLabel = reporterDisplayName ?? reporter.id; + const buttonLabel = supportsScopeChoice + ? 'Invalidate reports' + : 'Invalidate all reports from this reporter'; + + return ( + <> + + setVisible(false)} + footer={[ + { + title: 'Cancel', + onClick: () => setVisible(false), + type: 'secondary', + disabled: submitting, + }, + { + title: 'Invalidate', + onClick: onConfirm, + disabled: submitting, + loading: submitting, + }, + ]} + > +
+

+ {scopedToCurrentJob ? ( + <> + This removes every report from{' '} + {displayLabel} on this + review job. If they were the only reporter, the job is removed + from the queue. + + ) : ( + <> + This removes every report from{' '} + {displayLabel} across + every pending review job in your org. Jobs whose only reporter + was this user will be removed; jobs with other reporters will + remain. + + )} +

+ + setReason(event.target.value)} + placeholder="e.g. mass-flagging non-violating content" + rows={3} + maxLength={500} + /> +

+ Future reports from this user will still land normally; re-run this + action if needed. +

+ {supportsScopeChoice ? ( + + ) : null} +
+
+ + ); +} + +function formatSuccessMessage(opts: { + reportsRemoved: number; + jobsDeleted: number; + scope: 'currentJob' | 'orgWide'; +}): string { + const { reportsRemoved, jobsDeleted, scope } = opts; + if (reportsRemoved === 0) { + return 'No reports from this user were found.'; + } + const reportWord = reportsRemoved === 1 ? 'report' : 'reports'; + if (scope === 'currentJob') { + return jobsDeleted > 0 + ? `Removed ${reportsRemoved} ${reportWord}. This job had no other reporters and was cleared from the queue.` + : `Removed ${reportsRemoved} ${reportWord} from this job.`; + } + const base = `Removed ${reportsRemoved} ${reportWord} from this reporter.`; + if (jobsDeleted === 0) { + return base; + } + const jobWord = jobsDeleted === 1 ? 'job' : 'jobs'; + return `${base} ${jobsDeleted} ${jobWord} had no other reporters and ${ + jobsDeleted === 1 ? 'was' : 'were' + } cleared from the queue.`; +} diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx index 9acf876..1457661 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx @@ -152,6 +152,7 @@ gql` } me { id + permissions reviewableQueues { id name @@ -369,7 +370,11 @@ function ManualReviewJobReviewImpl(props: { setDecisionReason(undefined); }; - const { data, loading } = useGQLManualReviewJobInfoQuery({ + const { + data, + loading, + refetch: refetchJobInfo, + } = useGQLManualReviewJobInfoQuery({ variables: { jobIds: closedJob ? [closedJob.id] : jobId ? [jobId] : [] }, fetchPolicy: 'no-cache', }); @@ -409,23 +414,54 @@ function ManualReviewJobReviewImpl(props: { } }, [getNextJob, jobId, closedJob, loading, jobDataLoading, jobData]); + const [isAdvancingToNextJob, setIsAdvancingToNextJob] = useState(false); + // The jobId we deleted by invalidating its last report. Matching it by + // identity lets the redirect effect below skip its bounce-to-recent + // until we navigate onto the next job, with no timing window. + const invalidationDeletedJobIdRef = useRef(null); + useEffect(() => { - // If we were looking for a specific job and it no longer exists in this - // queue, redirect to the recent decisions page for it + // If the job we're viewing no longer exists in this queue, send the + // reviewer to recent decisions, unless we deleted it ourselves via + // invalidation (handleInvalidated advances to the next job instead). if ( - jobId != null && - data?.me?.reviewableQueues - .find((queue) => queue.id === queueId) - ?.jobs.find((job) => job.id === jobId) === undefined && - !loading + jobId == null || + closedJob || + loading || + isAdvancingToNextJob || + invalidationDeletedJobIdRef.current === jobId ) { - if (!closedJob) { - navigate(`/dashboard/manual_review/recent/?jobId=${jobId}`, { - replace: true, - }); - } + return; + } + const stillExists = data?.me?.reviewableQueues + .find((queue) => queue.id === queueId) + ?.jobs.find((job) => job.id === jobId); + if (stillExists) { + return; + } + navigate(`/dashboard/manual_review/recent/?jobId=${jobId}`, { + replace: true, + }); + }, [ + jobId, + data, + loading, + navigate, + queueId, + closedJob, + isAdvancingToNextJob, + ]); + + // Drop a stale claim once we've moved to a different job so returning + // to the deleted job (e.g. browser back) still routes to recent. + useEffect(() => { + if ( + invalidationDeletedJobIdRef.current != null && + invalidationDeletedJobIdRef.current !== jobId + ) { + invalidationDeletedJobIdRef.current = null; } - }, [jobId, data, loading, navigate, queueId, closedJob]); + }, [jobId]); // Modal-driven entry of action parameters. Opening the modal in `create` // mode happens *before* the action is added to `selectedPrimaryActions`, // so cancelling leaves the picker exactly as it was. `edit` mode opens @@ -701,6 +737,45 @@ function ManualReviewJobReviewImpl(props: { fetchPolicy: 'no-cache', }); + const advanceToNextJobAfterInvalidation = useCallback(async () => { + setIsAdvancingToNextJob(true); + try { + resetState(); + const result = await getNextJob(); + if (result.data?.dequeueManualReviewJob == null) { + navigate('/dashboard/manual_review/queues'); + } + } finally { + setIsAdvancingToNextJob(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getNextJob, navigate]); + + // Runs after the invalidate mutation resolves. Refreshes the job view, + // and if invalidation deleted the current job, advances to the next one. + const handleInvalidated = useCallback(async () => { + if (jobId == null) return; + // Claim the job before refetching: the refetch may momentarily report + // it gone, and the redirect effect must not fire during that window. + invalidationDeletedJobIdRef.current = jobId; + try { + const refetched = await refetchJobInfo(); + const stillExists = refetched.data?.me?.reviewableQueues + .find((queue) => queue.id === queueId) + ?.jobs.find((j) => j.id === jobId); + if (stillExists) { + // Job survived (other reporters remain); release the claim and stay. + invalidationDeletedJobIdRef.current = null; + return; + } + await advanceToNextJobAfterInvalidation(); + } catch (e) { + // Release the claim so the redirect effect isn't suppressed forever. + invalidationDeletedJobIdRef.current = null; + throw e; + } + }, [jobId, queueId, refetchJobInfo, advanceToNextJobAfterInvalidation]); + const skipToNextJob = async () => { // First, release the lock on the current job and log the skip if (queueId && job?.id && lockToken) { @@ -864,6 +939,11 @@ function ManualReviewJobReviewImpl(props: { isAppeal && 'actionsTaken' in payload ? payload.actionsTaken.map(getActionName) : undefined; + const canInvalidateReports = + !isAppeal && + userHasPermissions(data.me?.permissions ?? undefined, [ + GQLUserPermission.EditMrtQueues, + ]); const reportInfo = ( ); const otherReports = @@ -886,6 +968,9 @@ function ManualReviewJobReviewImpl(props: { ) : null; const decisionActions = [ diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.test.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.test.tsx new file mode 100644 index 0000000..b106890 --- /dev/null +++ b/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.test.tsx @@ -0,0 +1,105 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +import '@testing-library/jest-dom/extend-expect'; + +import { + GQLGetUserItemsDocument, + GQLPoliciesDocument, +} from '@/graphql/generated'; + +import MergedReportsComponent from './MergedReportsComponent'; + +// Regression: previously only the latest reporter was actionable; any +// reporter in the merged table had no invalidate button. + +const reporterA = { + id: 'reporter_a', + typeId: 'user_type', + __typename: 'ItemIdentifier', +}; +const reporterB = { + id: 'reporter_b', + typeId: 'user_type', + __typename: 'ItemIdentifier', +}; + +const reportHistory = [ + { + reportId: 'r_primary', + reportedAt: new Date('2026-05-27T10:00:00Z'), + policyId: null, + reason: null, + reporterId: reporterA, + }, + { + reportId: 'r_other_1', + reportedAt: new Date('2026-05-27T09:00:00Z'), + policyId: null, + reason: null, + reporterId: reporterB, + }, + { + reportId: 'r_other_2', + reportedAt: new Date('2026-05-27T08:00:00Z'), + policyId: null, + reason: null, + reporterId: reporterA, + }, +]; + +// Minimal stubs; component degrades gracefully when these resolve empty. +const baseMocks = [ + { + request: { + query: GQLGetUserItemsDocument, + variables: { + itemIdentifiers: [ + { id: reporterB.id, typeId: reporterB.typeId }, + { id: reporterA.id, typeId: reporterA.typeId }, + ], + }, + }, + result: { data: { latestItemSubmissions: [] } }, + }, + { + request: { query: GQLPoliciesDocument }, + result: { data: { myOrg: { id: 'org', policies: [], __typename: 'Org' } } }, + }, +]; + +function renderMerged(canInvalidateReports: boolean) { + return render( + + + + + , + ); +} + +describe('MergedReportsComponent invalidation actions', () => { + it('renders an invalidate button on every non-primary report row when the viewer has permission', () => { + renderMerged(true); + // Expand the table; collapsed by default. + screen.getByRole('button', { name: /show/i }).click(); + const buttons = screen.getAllByRole('button', { + name: /invalidate reports/i, + }); + expect(buttons).toHaveLength(2); + }); + + it('renders no invalidate buttons when the viewer lacks permission', () => { + renderMerged(false); + screen.getByRole('button', { name: /show/i }).click(); + expect( + screen.queryByRole('button', { name: /invalidate reports/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx index 2911544..926b511 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/MergedReportsComponent.tsx @@ -12,6 +12,8 @@ import { Link } from 'react-router-dom'; import Table from '../../components/table/Table'; +import InvalidateReportsButton from './InvalidateReportsButton'; + export default function MergedReportsComponent(props: { primaryReportId?: string | null; reportHistory: ReadonlyArray<{ @@ -24,13 +26,21 @@ export default function MergedReportsComponent(props: { typeId: string; } | null; }>; + // Parent gates this on EDIT_MRT_QUEUES and non-appeal. + canInvalidateReports?: boolean; + jobId?: string; + onInvalidated?: () => Promise | void; }) { - const { primaryReportId, reportHistory } = props; - // The primary report is shown separately above this component. If we know - // its reportId, exclude it from the merged list; otherwise fall back to - // dropping the first entry (newest, which corresponds to the displayed - // primary report after a job merge). Filtering by `reportId` is stable - // across reports that happen to share the same `reportedAt` timestamp. + const { + primaryReportId, + reportHistory, + canInvalidateReports, + jobId, + onInvalidated, + } = props; + // The primary report is rendered separately above this component. Filter + // by `reportId` when available (stable across duplicate timestamps); + // otherwise drop the newest entry, which corresponds to the primary. const otherReports = useMemo(() => { if (primaryReportId != null) { const matchIdx = reportHistory.findIndex( @@ -101,52 +111,46 @@ export default function MergedReportsComponent(props: { [], ); - const tableData = useMemo( - () => - reportHistoryWithDisplayInfo.map((report) => { - const policy = data?.myOrg?.policies.find( - (p) => p.id === report.policyId, - ); - const hasReporter = report.reporterId != null; - // Distinguish "no reporter on the report" (e.g. rule-engine, NCMEC, - // or other system-generated enqueues) from "we couldn't resolve the - // user item" so reviewers don't see a misleading "Unknown". - const reportedByLabel = !hasReporter - ? 'System' - : report.displayInfo?.displayName ?? - report.reporterId?.id ?? - 'Unknown reporter'; - const reportedByPrefix = - hasReporter && report.displayInfo?.typeName - ? `${report.displayInfo.typeName}: ` - : ''; - return { - reportedBy: ( -
+ const tableData = useMemo(() => { + // One invalidate button per reporter, on their first row. + const seenReporters = new Set(); + return reportHistoryWithDisplayInfo.map((report) => { + const policy = data?.myOrg?.policies.find( + (p) => p.id === report.policyId, + ); + const hasReporter = report.reporterId != null; + const reporterKey = report.reporterId + ? `${report.reporterId.typeId}\u241F${report.reporterId.id}` + : null; + const showInvalidate = + canInvalidateReports && + hasReporter && + reporterKey != null && + !seenReporters.has(reporterKey); + if (reporterKey != null) { + seenReporters.add(reporterKey); + } + // Distinguish system-generated enqueues (no reporter) from an + // unresolvable user-item lookup so reviewers don't see "Unknown". + const reportedByLabel = !hasReporter + ? 'System' + : (report.displayInfo?.displayName ?? + report.reporterId?.id ?? + 'Unknown reporter'); + const reportedByPrefix = + hasReporter && report.displayInfo?.typeName + ? `${report.displayInfo.typeName}: ` + : ''; + return { + reportedBy: ( +
+ {reportedByPrefix} {reportedByLabel} - {hasReporter && report.reporterId ? ( - - - - ) : null} -
- ), - reportedFor: policy ? ( -
- {policy.name} + + {hasReporter && report.reporterId ? ( @@ -155,21 +159,60 @@ export default function MergedReportsComponent(props: { size="icon" variant="link" endIcon={ExternalLink} - aria-label={`Open policy ${policy.name}`} + aria-label="Open reporter investigation page" > -
- ) : ( - '—' - ), - reason: report.reason?.trim() ? report.reason : '—', - reportTime: parseDatetimeToReadableStringInCurrentTimeZone( - report.reportedAt, - ), - }; - }), - [data?.myOrg?.policies, reportHistoryWithDisplayInfo], - ); + ) : null} + {showInvalidate && report.reporterId ? ( + + ) : null} +
+ ), + reportedFor: policy ? ( +
+ {policy.name} + + + +
+ ) : ( + '—' + ), + reason: report.reason?.trim() ? report.reason : '—', + reportTime: parseDatetimeToReadableStringInCurrentTimeZone( + report.reportedAt, + ), + }; + }); + }, [ + data?.myOrg?.policies, + reportHistoryWithDisplayInfo, + canInvalidateReports, + jobId, + onInvalidated, + ]); const otherReportsTable = ( diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/ReportInfoComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/ReportInfoComponent.tsx index 64da264..8dd2187 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/ReportInfoComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/ReportInfoComponent.tsx @@ -5,9 +5,11 @@ import { GQLNcmecFileAnnotation, GQLNcmecIndustryClassification, GQLSchemaFieldRoles, + GQLUserPermission, useGQLGetMoreInfoForItemsQuery, useGQLGetUserItemsQuery, } from '@/graphql/generated'; +import { userHasPermissions } from '@/routing/permissions'; import { filterNullOrUndefined } from '@/utils/collections'; import { getFieldValueForRole } from '@/utils/itemUtils'; import { selectPreferredUserItem } from '@/utils/manualReviewTool'; @@ -18,6 +20,7 @@ import { Link } from 'react-router-dom'; import CopyTextComponent from '@/components/common/CopyTextComponent'; +import InvalidateReportsButton from './InvalidateReportsButton'; import { ManualReviewJobPayload } from './ManualReviewJobReview'; import ManualReviewJobCommentSection from './v2/ManualReviewJobCommentSection'; @@ -44,6 +47,8 @@ export default function ReportInfoComponent(props: { orgId: string; allItemTypes: GQLItemType[]; policies: readonly { id: string; name: string }[]; + viewerPermissions?: readonly GQLUserPermission[]; + onInvalidated?: () => Promise | void; }) { const { reportPayload: payload, @@ -57,7 +62,12 @@ export default function ReportInfoComponent(props: { allItemTypes, actionsTaken, policies, + viewerPermissions, + onInvalidated, } = props; + const canInvalidateReports = + !isAppeal && + userHasPermissions(viewerPermissions, [GQLUserPermission.EditMrtQueues]); const reportedItem = payload.item; const reportedForReasons = payload.__typename === 'UserManualReviewJobPayload' || @@ -180,10 +190,12 @@ export default function ReportInfoComponent(props: { latestReporterIdentifier.typeId, ); return ( -
- {`${typeName}: `} - {reporterDisplayName ?? - latestReporterIdentifier.id} +
+ + {`${typeName}: `} + {reporterDisplayName ?? + latestReporterIdentifier.id} + + {canInvalidateReports && ( + + )}
); } diff --git a/db/src/scripts/api-server-pg/2026.05.27T21.33.27.widen_mrt_job_id_columns.sql b/db/src/scripts/api-server-pg/2026.05.27T21.33.27.widen_mrt_job_id_columns.sql new file mode 100644 index 0000000..9e78a9f --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.05.27T21.33.27.widen_mrt_job_id_columns.sql @@ -0,0 +1,46 @@ +-- Widen MRT job-id and item-id columns to text. +-- +-- `job_creations.id` stores the external JobId, which is double-b64-encoded +-- as `b64(b64(typeId) + '.' + b64(itemId)) + ':' + b64(guid)`. Caller-defined +-- item ids longer than ~70 chars overflow varchar(255), and the insert is +-- silently swallowed by the enqueue path, leaving the recovery table +-- (`recoverMrtQueueLib.ts`) out of sync with Redis. `job_comments.job_id` +-- and `moderator_skips.job_id` store the same external JobId. +-- +-- `item_id` / `item_type_id` are platform-defined and not length-bounded by +-- us, so we widen them too. `manual_review_decisions.id` is already `uuid` +-- (it only stores the guid portion via `jobIdToGuid`). +-- +-- varchar -> text is binary-coercible (no table rewrite), but Postgres +-- still refuses ALTER COLUMN TYPE when a view depends on the column, hence +-- the drop/recreate around `flattened_job_creations`. + + +DROP VIEW manual_review_tool.flattened_job_creations; + +ALTER TABLE manual_review_tool.job_creations + ALTER COLUMN id TYPE text, + ALTER COLUMN item_id TYPE text, + ALTER COLUMN item_type_id TYPE text; + +ALTER TABLE manual_review_tool.job_comments + ALTER COLUMN job_id TYPE text; + +ALTER TABLE manual_review_tool.moderator_skips + ALTER COLUMN job_id TYPE text; + +CREATE VIEW manual_review_tool.flattened_job_creations AS + SELECT job_creations.id, + job_creations.org_id, + job_creations.queue_id, + job_creations.item_id, + job_creations.item_type_id, + job_creations.created_at, + (job_creations.enqueue_source_info ->> 'kind'::text) AS source_kind, + rule_id.value AS rule_id, + policy_id.policy_id + FROM ((manual_review_tool.job_creations + LEFT JOIN LATERAL jsonb_array_elements_text((job_creations.enqueue_source_info -> 'rules'::text)) rule_id(value) ON (true)) + LEFT JOIN LATERAL unnest(job_creations.policy_ids) policy_id(policy_id) ON (true)); + +ALTER TABLE manual_review_tool.flattened_job_creations OWNER TO CURRENT_USER; diff --git a/docs/user/reports.md b/docs/user/reports.md index 18f3e19..4a6a8a1 100644 --- a/docs/user/reports.md +++ b/docs/user/reports.md @@ -19,6 +19,16 @@ If `reportedForReason.csam` is `true`, the job is routed directly to the NCMEC q Reports are submitted via `POST /api/v1/report`. For the full API schema (field definitions, types, and requirements), see the [Report API](../api/report.md) reference. +## Invalidating reports from a bad-faith reporter + +If a single user on your platform is mass-flagging non-violating content and clogging the review queue, moderators with the `EDIT_MRT_QUEUES` permission can invalidate every pending report from that reporter via the "Invalidate reports" action on any report's detail view in the Manual Review Tool. + +By default the action is scoped to the current job: it strips this reporter's entries from the job's report history. If the job has no other reporters or report sources (for example, automated detectors) left afterwards, the job is removed from the queue. To instead sweep every pending job in your organization, tick "Apply across the whole organization" in the confirmation modal. Decided or closed jobs are never modified. + +This is a one-shot operation, not a persistent blocklist: future reports from the same reporter will land normally and need to be invalidated again if the behavior continues. Address recurring bad-faith reporters by banning or silencing them at the platform layer. + +Each invalidation emits a trace span on the server with the moderator who performed it, the targeted reporter, an optional reason, and the resulting counts; the span is the current source of truth for ad-hoc audit lookups. + ## Appeals If a user wants to contest a moderation decision, that's handled through the Appeals API, a separate flow from reports. See [Appeals](appeals.md) for details. diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 85356a4..9cc2fde 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -1537,6 +1537,27 @@ export type GQLIntegrationNoInputCredentialsError = GQLError & { readonly type: ReadonlyArray; }; +export type GQLInvalidateReportsFromReporterInput = { + /** + * Scopes the sweep to a single MRT job. When omitted, every pending job + * in the caller's org is scanned. + */ + readonly jobId?: InputMaybe; + readonly reason?: InputMaybe; + readonly reporter: GQLReporterIdInput; +}; + +export type GQLInvalidateReportsFromReporterSuccessResponse = { + readonly __typename?: 'InvalidateReportsFromReporterSuccessResponse'; + readonly jobsDeleted: Scalars['Int']['output']; + readonly jobsScanned: Scalars['Int']['output']; + readonly jobsScrubbed: Scalars['Int']['output']; + readonly queuesScanned: Scalars['Int']['output']; + readonly reportsRemoved: Scalars['Int']['output']; + /** True when a queue exceeded the per-queue scan cap, so the sweep was partial. */ + readonly truncated: Scalars['Boolean']['output']; +}; + export type GQLInviteUserInput = { readonly email: Scalars['String']['input']; readonly role: GQLUserRole; @@ -2515,6 +2536,14 @@ export type GQLMutation = { readonly deleteUser?: Maybe; readonly dequeueManualReviewJob?: Maybe; readonly generatePasswordResetToken?: Maybe; + /** + * Strips every entry sent by the given reporter from the report history of + * every pending MRT job in the caller's org. If a job's history becomes + * empty and it was originally enqueued from a user report, the job itself + * is removed. Intentionally non-persistent: future reports from the same + * reporter are NOT blocked. See issue #404. + */ + readonly invalidateReportsFromReporter: GQLInvalidateReportsFromReporterSuccessResponse; readonly inviteUser?: Maybe; readonly logSkip: Scalars['Boolean']['output']; readonly login: GQLLoginResponse; @@ -2717,6 +2746,10 @@ export type GQLMutationGeneratePasswordResetTokenArgs = { userId: Scalars['ID']['input']; }; +export type GQLMutationInvalidateReportsFromReporterArgs = { + input: GQLInvalidateReportsFromReporterInput; +}; + export type GQLMutationInviteUserArgs = { input: GQLInviteUserInput; }; @@ -5839,6 +5872,8 @@ export type GQLResolversTypes = { IntegrationEmptyInputCredentialsError: ResolverTypeWrapper; IntegrationMetadata: ResolverTypeWrapper; IntegrationNoInputCredentialsError: ResolverTypeWrapper; + InvalidateReportsFromReporterInput: GQLInvalidateReportsFromReporterInput; + InvalidateReportsFromReporterSuccessResponse: ResolverTypeWrapper; InviteUserInput: GQLInviteUserInput; InviteUserToken: ResolverTypeWrapper; InviteUserTokenExpiredError: ResolverTypeWrapper; @@ -6619,6 +6654,8 @@ export type GQLResolversParentTypes = { IntegrationEmptyInputCredentialsError: GQLIntegrationEmptyInputCredentialsError; IntegrationMetadata: GQLIntegrationMetadata; IntegrationNoInputCredentialsError: GQLIntegrationNoInputCredentialsError; + InvalidateReportsFromReporterInput: GQLInvalidateReportsFromReporterInput; + InvalidateReportsFromReporterSuccessResponse: GQLInvalidateReportsFromReporterSuccessResponse; InviteUserInput: GQLInviteUserInput; InviteUserToken: GQLInviteUserToken; InviteUserTokenExpiredError: GQLInviteUserTokenExpiredError; @@ -9257,6 +9294,20 @@ export type GQLIntegrationNoInputCredentialsErrorResolvers< __isTypeOf?: IsTypeOfResolverFn; }; +export type GQLInvalidateReportsFromReporterSuccessResponseResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['InvalidateReportsFromReporterSuccessResponse'] = + GQLResolversParentTypes['InvalidateReportsFromReporterSuccessResponse'], +> = { + jobsDeleted?: Resolver; + jobsScanned?: Resolver; + jobsScrubbed?: Resolver; + queuesScanned?: Resolver; + reportsRemoved?: Resolver; + truncated?: Resolver; +}; + export type GQLInviteUserTokenResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['InviteUserToken'] = @@ -10937,6 +10988,12 @@ export type GQLMutationResolvers< ContextType, RequireFields >; + invalidateReportsFromReporter?: Resolver< + GQLResolversTypes['InvalidateReportsFromReporterSuccessResponse'], + ParentType, + ContextType, + RequireFields + >; inviteUser?: Resolver< Maybe, ParentType, @@ -14763,6 +14820,7 @@ export type GQLResolvers = { IntegrationEmptyInputCredentialsError?: GQLIntegrationEmptyInputCredentialsErrorResolvers; IntegrationMetadata?: GQLIntegrationMetadataResolvers; IntegrationNoInputCredentialsError?: GQLIntegrationNoInputCredentialsErrorResolvers; + InvalidateReportsFromReporterSuccessResponse?: GQLInvalidateReportsFromReporterSuccessResponseResolvers; InviteUserToken?: GQLInviteUserTokenResolvers; InviteUserTokenExpiredError?: GQLInviteUserTokenExpiredErrorResolvers; InviteUserTokenMissingError?: GQLInviteUserTokenMissingErrorResolvers; diff --git a/server/graphql/modules/manualReviewTool.ts b/server/graphql/modules/manualReviewTool.ts index 3d252c0..70ca786 100644 --- a/server/graphql/modules/manualReviewTool.ts +++ b/server/graphql/modules/manualReviewTool.ts @@ -451,6 +451,28 @@ const typeDefs = /* GraphQL */ ` | DeleteAllJobsFromQueueSuccessResponse | DeleteAllJobsUnauthorizedError + input InvalidateReportsFromReporterInput { + reporter: ReporterIdInput! + reason: String + """ + Scopes the sweep to a single MRT job. When omitted, every pending job + in the caller's org is scanned. + """ + jobId: ID + } + + type InvalidateReportsFromReporterSuccessResponse { + queuesScanned: Int! + jobsScanned: Int! + jobsScrubbed: Int! + jobsDeleted: Int! + reportsRemoved: Int! + """ + True when a queue exceeded the per-queue scan cap, so the sweep was partial. + """ + truncated: Boolean! + } + enum MetricsTimeDivisionOptions { DAY HOUR @@ -949,6 +971,16 @@ const typeDefs = /* GraphQL */ ` input: RemoveAccessibleQueuesToUserInput! ): RemoveAccessibleQueuesToUserResponse! deleteAllJobsFromQueue(queueId: ID!): DeleteAllJobsFromQueueResponse! + """ + Strips every entry sent by the given reporter from the report history of + every pending MRT job in the caller's org. If a job's history becomes + empty and it was originally enqueued from a user report, the job itself + is removed. Intentionally non-persistent: future reports from the same + reporter are NOT blocked. See issue #404. + """ + invalidateReportsFromReporter( + input: InvalidateReportsFromReporterInput! + ): InvalidateReportsFromReporterSuccessResponse! createManualReviewJobComment( input: CreateManualReviewJobCommentInput! ): AddManualReviewJobCommentResponse! @@ -2444,6 +2476,32 @@ const Mutation: GQLMutationResolvers = { throw e; } }, + async invalidateReportsFromReporter(_, { input }, context) { + const user = context.getUser(); + if (user == null) { + throw unauthenticatedError('Authenticated user required'); + } + const permissions = user.getPermissions(); + if (!permissions.includes(UserPermission.EDIT_MRT_QUEUES)) { + throw forbiddenError( + 'User does not have permission to invalidate reports', + ); + } + + return context.services.ManualReviewToolService.invalidateReportsFromReporter( + { + orgId: user.orgId, + reporter: { typeId: input.reporter.typeId, id: input.reporter.id }, + reason: input.reason ?? undefined, + jobId: input.jobId ?? undefined, + invokedBy: { + userId: user.id, + permissions, + orgId: user.orgId, + }, + }, + ); + }, async createManualReviewJobComment(_, params, context) { const user = context.getUser(); if (user == null) { diff --git a/server/services/manualReviewToolService/manualReviewToolService.ts b/server/services/manualReviewToolService/manualReviewToolService.ts index f3eb38e..3a55592 100644 --- a/server/services/manualReviewToolService/manualReviewToolService.ts +++ b/server/services/manualReviewToolService/manualReviewToolService.ts @@ -9,7 +9,10 @@ import { type Opaque } from 'type-fest'; import { type Dependencies } from '../../iocContainer/index.js'; import { type ConsumerDirectives } from '../../lib/cache/index.js'; import { jsonStringify } from '../../utils/encoding.js'; -import { isCoopErrorOfType } from '../../utils/errors.js'; +import { + isCoopErrorOfType, + makeUnauthorizedError, +} from '../../utils/errors.js'; import { isUniqueViolationError } from '../../utils/kysely.js'; import type { OmitEach, ReplaceDeep } from '../../utils/typescript-types.js'; import { @@ -21,8 +24,8 @@ import { type ItemSubmissionWithTypeIdentifier } from '../itemProcessingService/ import { type ModerationConfigService } from '../moderationConfigService/index.js'; import { type PartialItemsService } from '../partialItemsService/index.js'; import { + UserPermission, type Invoker, - type UserPermission, } from '../userManagementService/index.js'; import { type UserScore, @@ -58,6 +61,10 @@ import ManualReviewToolSettings from './modules/ManualReviewToolSettings.js'; import QueueOperations, { type ManualReviewQueue, } from './modules/QueueOperations.js'; +import ReporterInvalidation, { + type InvalidateReportsFromReporterInput, + type InvalidateReportsFromReporterResult, +} from './modules/ReporterInvalidation.js'; import SkipOperations, { type SkippedJobCountInput, } from './modules/SkipOperations.js'; @@ -283,6 +290,7 @@ export class ManualReviewToolService { private readonly manualReviewToolSettings: ManualReviewToolSettings; private readonly commentOps: CommentOperations; private readonly skipOps: SkipOperations; + private readonly reporterInvalidation: ReporterInvalidation; constructor( readonly redis: Dependencies['IORedis'], @@ -339,6 +347,10 @@ export class ManualReviewToolService { this.decisionAnalytics = new DecisionAnalytics(pgQueryReadReplica); this.commentOps = new CommentOperations(pgQuery); this.skipOps = new SkipOperations(pgQuery); + this.reporterInvalidation = new ReporterInvalidation( + this.queueOps, + this.tracer, + ); } /** @@ -478,14 +490,13 @@ export class ManualReviewToolService { item_id: job.payload.item.itemId, item_type_id: job.payload.item.itemTypeIdentifier.id, queue_id: targetQueueForNewJob, - // We use the Source Info from the input argument to account - // for the case that we are in fact updating a job which was - // never inserted into this table and did not have enqueue source - // info, but the updated job will. enqueue_source_info: input.enqueueSourceInfo, policy_ids: input.policyIds, created_at: new Date(), }) + // Re-enqueues (merged reports) re-run this path; keep the + // original row. + .onConflict((oc) => oc.column('id').doNothing()) .execute() .catch(() => {}); // don't throw if logging fails @@ -603,6 +614,7 @@ export class ManualReviewToolService { policy_ids: input.policyIds, created_at: new Date(), }) + .onConflict((oc) => oc.column('id').doNothing()) .execute() .catch(() => {}); // don't throw if logging fails @@ -1173,6 +1185,33 @@ export class ManualReviewToolService { return this.queueOps.deleteAllJobsFromQueue(opts); } + /** + * Strips every entry sent by `reporter` from the `reportHistory` and + * `reportedForReasons` of every pending MRT job in the org. A job whose + * history empties out is removed if it was enqueued purely from a user + * report. One-shot with no persistent blocklist. See issue #404. + */ + async invalidateReportsFromReporter( + input: InvalidateReportsFromReporterInput, + ): Promise { + // Also gated at the GraphQL resolver; re-checked here for in-process + // callers such as server bin scripts. + if (!input.invokedBy.permissions.includes(UserPermission.EDIT_MRT_QUEUES)) { + throw makeUnauthorizedError( + 'You do not have permission to invalidate reports', + { shouldErrorSpan: true }, + ); + } + // Bind the sweep to the caller's own org. + if (input.invokedBy.orgId !== input.orgId) { + throw makeUnauthorizedError( + 'You do not have permission to invalidate reports for this org', + { shouldErrorSpan: true }, + ); + } + return this.reporterInvalidation.invalidateReportsFromReporter(input); + } + async submitDecision( opts: OmitEach & { jobId: string }, ) { diff --git a/server/services/manualReviewToolService/modules/QueueOperations.ts b/server/services/manualReviewToolService/modules/QueueOperations.ts index 5c368d2..c47fcce 100644 --- a/server/services/manualReviewToolService/modules/QueueOperations.ts +++ b/server/services/manualReviewToolService/modules/QueueOperations.ts @@ -845,13 +845,223 @@ export default class QueueOperations { const queue = await this.#getBullQueue(orgId, queueId); const { bullId } = parseExternalId(jobId); const job = await queue.getJob(bullId); - await job?.updateData(data); + // updateData is unlocked; abort if the slot was already taken over by + // a new job for the same item (different external id) so we don't + // clobber a reviewer's in-flight decision payload. + if (!job || job.data.id !== jobId) { + return undefined; + } + await job.updateData(data); // Because the `data` arg above is a ManualReviewJob, we know the stored // data for this particular job won't be in the legacy format. - return job?.data satisfies StoredManualReviewJob | undefined as - | ManualReviewJob - | undefined; + return job.data satisfies StoredManualReviewJob as ManualReviewJob; + } + + /** + * Yields every undecided job on a queue (waiting, delayed, or active) for + * bounded admin sweeps such as reporter invalidation. Includes `active` + * jobs so sweeps can update what a reviewer currently has dequeued; + * excludes terminal states (completed/failed). `maxJobs` caps a single + * sweep so it can't pin Redis indefinitely. + * + * Iterates in two phases: first snapshots all external JobIds (bounded + * by `maxJobs`), then fetches each by id and yields it. This keeps the + * iterator safe when callers delete or update jobs mid-traversal, which + * index-based pagination over a mutating list would not. + */ + async *iteratePendingJobsForQueue(opts: { + orgId: string; + queueId: string; + batchSize?: number; + maxJobs?: number; + // Set `truncated` when the queue exceeded `maxJobs`. + progress?: { truncated: boolean }; + }): AsyncIterable { + const { orgId, queueId } = opts; + const batchSize = Math.max(1, Math.min(opts.batchSize ?? 200, 500)); + const maxJobs = Math.max(0, opts.maxJobs ?? 10_000); + + const queue = await this.#getBullQueue(orgId, queueId); + + const snapshotIds: JobId[] = []; + let start = 0; + while (snapshotIds.length < maxJobs) { + const end = start + batchSize - 1; + const legacyJobs = await queue.getJobs( + ['waiting', 'delayed', 'active'], + start, + end, + ); + if (legacyJobs.length === 0) { + break; + } + for (const legacy of legacyJobs) { + if (snapshotIds.length >= maxJobs) { + break; + } + snapshotIds.push(legacy.data.id); + } + if (legacyJobs.length < batchSize) { + break; + } + start += batchSize; + } + + if (opts.progress != null) { + opts.progress.truncated = snapshotIds.length >= maxJobs; + } + + for (const jobId of snapshotIds) { + // `getJobs` re-reads each job and converts to the current format. If + // the job was decided / removed between snapshot and now, the result + // is empty and we silently skip it. + const jobs = await this.getJobs({ orgId, queueId, jobIds: [jobId] }); + if (jobs.length > 0) { + yield jobs[0]; + } + } + } + + /** + * Looks up a pending job by its external JobId without requiring a + * queueId. Fast path via `job_creations`; falls back to a per-queue + * Bull lookup (keyed on the derived BullJobId) for jobs whose + * `job_creations` row never landed. + * + * NB: the fallback path is O(non-appeal queues) Redis round-trips per + * call. Acceptable for admin-triggered actions (button click) but do + * not call from hot paths. + * + * Returns undefined when the job is no longer pending, or when the + * external id is malformed (admin pasted a stale / wrong id). + */ + async findPendingJobByJobId(opts: { + orgId: string; + jobId: JobId; + }): Promise<{ job: ManualReviewJob; queueId: string } | undefined> { + const { orgId, jobId } = opts; + // External JobIds are `:`. Reject + // anything that doesn't parse so we don't blow up the per-queue + // fallback below for stale / typo'd ids. + if (!isParsableExternalId(jobId)) { + return undefined; + } + const row = await this.pgQuery + .selectFrom('manual_review_tool.job_creations') + .select(['queue_id']) + .where('org_id', '=', orgId) + .where('id', '=', jobId) + .executeTakeFirst(); + if (row) { + const jobs = await this.getJobs({ + orgId, + queueId: row.queue_id, + jobIds: [jobId], + }); + if (jobs.length > 0) { + return { job: jobs[0], queueId: row.queue_id }; + } + } + const queues = + await this.getAllQueuesForOrgAndDangerouslyBypassPermissioning(orgId); + for (const queue of queues) { + if (queue.isAppealsQueue) { + continue; + } + const jobs = await this.getJobs({ + orgId, + queueId: queue.id, + jobIds: [jobId], + }); + if (jobs.length > 0) { + return { job: jobs[0], queueId: queue.id }; + } + } + return undefined; + } + + /** + * Removes a pending job from a queue by its external JobId without + * requiring a lock token. Used by admin-triggered bulk maintenance + * (e.g. invalidating reports from a reporter). + * + * Returns true if the job was removed, false if it was already gone. + * The Bull-internal `BullJobId` is derived from the external JobId, and + * we verify `job.data.id === externalId` before removal so a stale + * lookup that finds a *different* job for the same item is a no-op. + */ + async removeJobByJobIdUnsafe(opts: { + orgId: string; + queueId: string; + jobId: JobId; + }): Promise { + const { orgId, queueId, jobId } = opts; + const queue = await this.getOrCreateBullQueue({ orgId, queueId }); + const bullJobId = parseExternalId(jobId).bullId; + + const job = await queue.getJob(bullJobId); + if (!job || job.data.id !== jobId) { + return false; + } + // Bull's `remove` throws when the job is currently locked by a worker; + // we want callers to fall back to scrub-in-place in that case. Other + // errors (e.g. Redis transient failures) must propagate so the caller + // doesn't conflate them with "already gone". + try { + const status = await queue.remove(bullJobId); + return status === 1; + } catch (err: unknown) { + if (isJobLockedError(err)) { + return false; + } + throw err; + } + } + + /** + * Removes a pending job. Tries an unlocked `remove`; if the job is + * locked, atomically completes it with `invokerUserId` as the lock + * token (per the `lockToken === userId` convention) so a reviewer + * deleting a job they themselves dequeued succeeds without stealing + * another reviewer's lock. Returns `false` on token mismatch so + * callers can fall back to scrubbing. + */ + async removeJobAllowingInvokerLock(opts: { + orgId: string; + queueId: string; + jobId: JobId; + invokerUserId: string; + }): Promise { + const { orgId, queueId, jobId, invokerUserId } = opts; + const queue = await this.getOrCreateBullQueue({ orgId, queueId }); + const { bullId: bullJobId } = parseExternalId(jobId); + const job = await queue.getJob(bullJobId); + if (!job || job.data.id !== jobId) { + return false; + } + + try { + const status = await queue.remove(bullJobId); + if (status === 1) { + return true; + } + } catch (err: unknown) { + if (!isJobLockedError(err)) { + throw err; + } + // Locked: fall through to the token-validated path. + } + + try { + await job.moveToCompleted(null, invokerUserId, false); + return true; + } catch { + // Lock token mismatch (different user) or the job's state moved + // between getJob and moveToCompleted. Either way, caller should + // scrub. + return false; + } } async deleteAllJobsFromQueue(opts: { @@ -1588,6 +1798,41 @@ export const makeUnableToDeleteDefaultQueueError = ( }); }; +/** + * Cheap, non-throwing parse check for external JobIds. Used by callers + * that accept the id as user input (e.g. admin button) so a malformed id + * becomes a "not found" instead of a 500. + */ +// Both halves are base64url tokens (optionally `=`-padded), so reject input +// that can't be one before paying for the per-queue fallback scan. +const B64URL_TOKEN = /^[A-Za-z0-9_\-+/=]+$/; +function isParsableExternalId(externalId: JobId): boolean { + const parts = (externalId as string).split(':'); + return ( + parts.length === 2 && + B64URL_TOKEN.test(parts[0]) && + B64URL_TOKEN.test(parts[1]) + ); +} + +/** + * BullMQ surfaces "job is locked" as an Error whose message starts with + * "Could not remove job"; there is no exported error class to instanceof + * against. Match defensively on the message and on a likely future + * canonicalisation of the same condition. + */ +function isJobLockedError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false; + } + const msg = err.message.toLowerCase(); + return ( + msg.includes('could not remove job') || + msg.includes('locked by another worker') || + msg.includes('lock mismatch') + ); +} + export const makeManualReviewQueueNameExistsError = (data: ErrorInstanceData) => new CoopError({ status: 409, diff --git a/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts b/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts new file mode 100644 index 0000000..1bc4008 --- /dev/null +++ b/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts @@ -0,0 +1,754 @@ +/* eslint-disable max-lines */ +import { uid } from 'uid'; +import { v1 as uuidv1 } from 'uuid'; + +import getBottle from '../../../iocContainer/index.js'; +import createContentItemTypes from '../../../test/fixtureHelpers/createContentItemTypes.js'; +import createMrtQueue from '../../../test/fixtureHelpers/createMrtQueue.js'; +import createOrg from '../../../test/fixtureHelpers/createOrg.js'; +import createUser from '../../../test/fixtureHelpers/createUser.js'; +import { makeTestWithFixture } from '../../../test/utils.js'; +import { instantiateOpaqueType } from '../../../utils/typescript-types.js'; +import { + makeSubmissionId, + type NormalizedItemData, +} from '../../itemProcessingService/index.js'; +import { type ItemSubmissionWithTypeIdentifier } from '../../itemProcessingService/makeItemSubmissionWithTypeIdentifier.js'; +import { UserPermission } from '../../userManagementService/index.js'; +import { + type ContentManualReviewJobPayload, + type NcmecManualReviewJobPayload, + type ReportHistory, +} from '../manualReviewToolService.js'; +import { scrubPayloadForReporter } from './ReporterInvalidation.js'; + +function makeItem(): ItemSubmissionWithTypeIdentifier { + return instantiateOpaqueType({ + submissionId: makeSubmissionId(), + submissionTime: new Date(), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + data: {} as NormalizedItemData, + itemTypeIdentifier: { + id: uuidv1(), + version: new Date().toISOString(), + schemaVariant: 'original', + }, + creator: { id: uuidv1(), typeId: uuidv1() }, + itemId: uuidv1(), + }); +} + +function makeReportHistoryEntry(reporter: { + typeId: string; + id: string; +}): ReportHistory[number] { + return { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: reporter, + reason: 'spam', + }; +} + +describe('scrubPayloadForReporter (pure)', () => { + const badReporter = { typeId: 'user_type', id: 'bad_reporter_1' }; + const goodReporter = { typeId: 'user_type', id: 'good_reporter_1' }; + + it('returns the payload unchanged with removedCount=0 when no entries match', () => { + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [ + makeReportHistoryEntry(goodReporter), + makeReportHistoryEntry({ typeId: 'user_type', id: 'other_user' }), + ], + reportedForReasons: [ + { reporterId: goodReporter, reason: 'spam' }, + { + reporterId: { typeId: 'user_type', id: 'other_user' }, + reason: 'spam', + }, + ], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(0); + // No-op should return the same reference to avoid unnecessary writes. + expect(result.payload).toBe(payload); + }); + + it('filters matching entries from reportHistory AND reportedForReasons on DEFAULT payloads', () => { + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [ + makeReportHistoryEntry(badReporter), + makeReportHistoryEntry(goodReporter), + makeReportHistoryEntry(badReporter), + ], + reportedForReasons: [ + { reporterId: badReporter, reason: 'fake' }, + { reporterId: goodReporter, reason: 'spam' }, + ], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(2); + expect(result.payload.reportHistory).toHaveLength(1); + expect(result.payload.reportHistory[0]?.reporterId).toEqual(goodReporter); + expect( + (result.payload as ContentManualReviewJobPayload).reportedForReasons, + ).toHaveLength(1); + expect( + (result.payload as ContentManualReviewJobPayload).reportedForReasons?.[0] + ?.reporterId, + ).toEqual(goodReporter); + }); + + it('produces an empty reportHistory when every entry matches', () => { + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [ + makeReportHistoryEntry(badReporter), + makeReportHistoryEntry(badReporter), + ], + reportedForReasons: [{ reporterId: badReporter, reason: 'fake' }], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(2); + expect(result.payload.reportHistory).toHaveLength(0); + expect( + (result.payload as ContentManualReviewJobPayload).reportedForReasons, + ).toHaveLength(0); + }); + + it('scrubs reportedForReasons on legacy jobs whose reportHistory is empty', () => { + // legacyJobToJob synthesizes reportedForReasons from reporterIdentifier + // while leaving reportHistory empty, so the reporter only appears there. + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [], + reportedForReasons: [{ reporterId: badReporter, reason: 'fake' }], + reportedForReason: 'fake', + reporterIdentifier: badReporter, + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(1); + const scrubbed = result.payload as ContentManualReviewJobPayload; + expect(scrubbed.reportedForReasons).toHaveLength(0); + expect(scrubbed.reportHistory).toHaveLength(0); + expect(scrubbed.reporterIdentifier).toBeUndefined(); + }); + + it('repopulates reportedForReasons from the new newest history entry when scrubbing empties it', () => { + // Regression: scrubbing the newest reporter could leave + // `reportedForReasons` empty while `reportHistory` still had entries, + // hiding the next-newest report from both the primary panel and the + // "other reports" table. + const survivor = { typeId: 'user_type', id: 'survivor' }; + const survivorEntry = makeReportHistoryEntry(survivor); + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + // newest-to-oldest, per the order produced by `#mergeJobPayloads` + reportHistory: [makeReportHistoryEntry(badReporter), survivorEntry], + reportedForReasons: [{ reporterId: badReporter, reason: 'fake' }], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(1); + expect(result.payload.reportHistory).toHaveLength(1); + expect(result.payload.reportHistory[0]?.reporterId).toEqual(survivor); + + const reasons = (result.payload as ContentManualReviewJobPayload) + .reportedForReasons; + expect(reasons).toHaveLength(1); + expect(reasons?.[0]?.reporterId).toEqual(survivor); + expect(reasons?.[0]?.reason).toBe(survivorEntry.reason); + }); + + it('repoints legacy reportedForReason/reporterIdentifier when they referenced the scrubbed reporter', () => { + const survivor = { typeId: 'user_type', id: 'survivor' }; + const survivorEntry = makeReportHistoryEntry(survivor); + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [makeReportHistoryEntry(badReporter), survivorEntry], + reportedForReasons: [{ reporterId: badReporter, reason: 'fake' }], + reportedForReason: 'fake', + reporterIdentifier: badReporter, + }; + const result = scrubPayloadForReporter(payload, badReporter); + const scrubbed = result.payload as ContentManualReviewJobPayload; + expect(scrubbed.reporterIdentifier).toEqual(survivor); + expect(scrubbed.reportedForReason).toBe(survivorEntry.reason); + }); + + it('leaves legacy reporterIdentifier untouched when it referenced a different reporter', () => { + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [ + makeReportHistoryEntry(goodReporter), + makeReportHistoryEntry(badReporter), + ], + reportedForReasons: [{ reporterId: goodReporter, reason: 'spam' }], + reportedForReason: 'spam', + reporterIdentifier: goodReporter, + }; + const result = scrubPayloadForReporter(payload, badReporter); + const scrubbed = result.payload as ContentManualReviewJobPayload; + expect(scrubbed.reporterIdentifier).toEqual(goodReporter); + expect(scrubbed.reportedForReason).toBe('spam'); + }); + + it('does not match entries where the reporterId is undefined (rule-engine / system entries)', () => { + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: undefined, + reason: 'rule output', + }, + makeReportHistoryEntry(badReporter), + ], + reportedForReasons: [], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(1); + expect(result.payload.reportHistory).toHaveLength(1); + expect(result.payload.reportHistory[0]?.reporterId).toBeUndefined(); + }); + + it('matches on both typeId AND id, not either alone', () => { + const payload: ContentManualReviewJobPayload = { + kind: 'DEFAULT', + item: makeItem(), + reportHistory: [ + makeReportHistoryEntry({ + typeId: badReporter.typeId, + id: 'different_id', + }), + makeReportHistoryEntry({ + typeId: 'different_type', + id: badReporter.id, + }), + makeReportHistoryEntry(badReporter), + ], + reportedForReasons: [], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(1); + expect(result.payload.reportHistory).toHaveLength(2); + }); + + it('only touches reportHistory (not reportedForReasons) on NCMEC payloads', () => { + const payload: NcmecManualReviewJobPayload = { + kind: 'NCMEC', + item: makeItem(), + allMediaItems: [], + reportHistory: [ + makeReportHistoryEntry(badReporter), + makeReportHistoryEntry(goodReporter), + ], + }; + const result = scrubPayloadForReporter(payload, badReporter); + expect(result.removedCount).toBe(1); + expect(result.payload.kind).toBe('NCMEC'); + expect(result.payload.reportHistory).toHaveLength(1); + // NCMEC payloads have no `reportedForReasons`; make sure we didn't + // synthesize one. + expect( + ( + result.payload as NcmecManualReviewJobPayload as unknown as Record< + string, + unknown + > + ).reportedForReasons, + ).toBeUndefined(); + }); +}); + +// Integration tests below mirror the pattern in `QueueOperations.test.ts`. + +const testWithQueue = () => + makeTestWithFixture(async () => { + const container = (await getBottle()).container; + const { org, cleanup: orgCleanup } = await createOrg( + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, + uid(), + ); + const { user, cleanup: userCleanup } = await createUser( + container.KyselyPg, + org.id, + ); + const { itemTypes, cleanup: itemTypesCleanup } = + await createContentItemTypes({ + moderationConfigService: container.ModerationConfigService, + orgId: org.id, + extra: {}, + }); + const { queue, cleanup: queueCleanup } = await createMrtQueue({ + orgId: org.id, + mrtService: container.ManualReviewToolService, + userId: user.id, + }); + + const mrtService = container.ManualReviewToolService; + + // Bracket-index into the private QueueOperations to seed jobs directly, + // matching the pattern in manualReviewToolService.test.ts. + const queueOps = mrtService['queueOps']; + + const addJob = async (opts: { + itemId: string; + reportHistory: ReportHistory; + }) => { + const item = instantiateOpaqueType({ + submissionId: makeSubmissionId(), + submissionTime: new Date(), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + data: {} as NormalizedItemData, + itemTypeIdentifier: { + id: itemTypes[0].id, + version: new Date().toISOString(), + schemaVariant: 'original', + }, + creator: { id: uuidv1(), typeId: uuidv1() }, + itemId: opts.itemId, + }); + return queueOps.addJob({ + orgId: org.id, + queueId: queue.id, + enqueueSourceInfo: { kind: 'REPORT' }, + jobPayload: { + createdAt: new Date(), + policyIds: [], + payload: { + kind: 'DEFAULT', + item, + reportHistory: opts.reportHistory, + reportedForReasons: opts.reportHistory.map((r) => ({ + reporterId: r.reporterId, + reason: r.reason, + })), + }, + }, + }); + }; + + return { + org, + user, + queue, + mrtService, + addJob, + cleanup: async () => { + await queueCleanup(); + await itemTypesCleanup(); + await userCleanup(); + await orgCleanup(); + await container.KyselyPg.destroy(); + await container.KyselyPgReadReplica.destroy(); + }, + }; + }); + +describe('ManualReviewToolService.invalidateReportsFromReporter', () => { + const invoker = (orgId: string, userId: string) => ({ + userId, + permissions: [UserPermission.EDIT_MRT_QUEUES] as const, + orgId, + }); + + testWithQueue()( + 'rejects with UnauthorizedError when invoker lacks EDIT_MRT_QUEUES', + async ({ org, user, mrtService }) => { + await expect( + mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: { typeId: 'user_type', id: 'bad' }, + invokedBy: { userId: user.id, permissions: [], orgId: org.id }, + }), + ).rejects.toMatchObject({ name: 'UnauthorizedError', status: 403 }); + }, + ); + + testWithQueue()( + 'scrubs the bad reporter from a multi-reporter job and keeps the job', + async ({ org, user, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + const good = { typeId: 'user_type', id: 'good' }; + const itemId = uuidv1(); + await addJob({ + itemId, + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: good, + reason: 'legit', + }, + ], + }); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + + expect(result.jobsScrubbed).toBe(1); + expect(result.jobsDeleted).toBe(0); + expect(result.reportsRemoved).toBe(1); + }, + ); + + testWithQueue()( + 'deletes a job whose only report is from the bad reporter (REPORT-originated)', + async ({ org, user, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + const itemId = uuidv1(); + await addJob({ + itemId, + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + + expect(result.jobsDeleted).toBe(1); + expect(result.jobsScrubbed).toBe(0); + expect(result.reportsRemoved).toBe(1); + }, + ); + + testWithQueue()( + // A reviewer viewing a job (active, locked with their userId) + // invalidates the only reporter; the job should be deleted. + 'deletes a locked single-report job when the invoker is the lock holder', + async ({ org, user, queue, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + + const dequeued = await mrtService.dequeueNextJob({ + orgId: org.id, + queueId: queue.id, + userId: user.id, + }); + expect(dequeued).toBeTruthy(); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + + expect(result.reportsRemoved).toBe(1); + expect(result.jobsDeleted).toBe(1); + expect(result.jobsScrubbed).toBe(0); + }, + ); + + testWithQueue()( + // Safety: if a DIFFERENT reviewer holds the lock on the active job, + // we must not steal it. The job stays in the queue and the bad + // reporter's entry is scrubbed in place. + "scrubs (does not delete) when another reviewer's lock would have to be stolen", + async ({ org, user, queue, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + + // A different reviewer holds the lock. The lockToken is an opaque + // string in BullMQ, so no real second user is needed here. + const otherReviewerId = 'some-other-reviewer-id'; + const dequeued = await mrtService.dequeueNextJob({ + orgId: org.id, + queueId: queue.id, + userId: otherReviewerId, + }); + expect(dequeued).toBeTruthy(); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + + expect(result.reportsRemoved).toBe(1); + expect(result.jobsDeleted).toBe(0); + expect(result.jobsScrubbed).toBe(1); + }, + ); + + testWithQueue()( + // Regression for the iterator-skip bug: previously the sweep paginated + // by absolute index, so every removal shifted later indices down and + // jobs were silently skipped. Snapshot-then-process must visit every + // seeded job even with `batchSize` smaller than the queue. + 'visits every pending job even when removals happen mid-sweep', + async ({ org, user, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + const totalJobs = 5; + for (let i = 0; i < totalJobs; i++) { + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + } + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + // Force pagination smaller than the queue so the old index-based + // pagination would have skipped half the jobs. + batchSize: 2, + maxJobsPerQueue: 100, + }); + + expect(result.reportsRemoved).toBe(totalJobs); + expect(result.jobsDeleted).toBe(totalJobs); + expect(result.jobsScrubbed).toBe(0); + }, + ); + + testWithQueue()( + // Concurrency guard: a second org-wide sweep for the same (org, + // reporter) while one is in flight must be rejected. + 'rejects a concurrent org-wide sweep for the same (org, reporter)', + async ({ org, user, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + // Seed enough jobs that the first sweep has work to do; the second + // call races against it. + for (let i = 0; i < 3; i++) { + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + } + + const first = mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + const second = mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + + // The second one races but should either reject with SWEEP_IN_FLIGHT + // or, if the first finishes too quickly, return zeros (no work). + const [firstResult, secondOutcome] = await Promise.all([ + first, + second.then( + (value) => ({ kind: 'ok' as const, value }), + (err: unknown) => ({ kind: 'err' as const, err }), + ), + ]); + expect(firstResult.reportsRemoved).toBe(3); + if (secondOutcome.kind === 'err') { + expect((secondOutcome.err as { code?: string }).code).toBe( + 'SWEEP_IN_FLIGHT', + ); + } else { + expect(secondOutcome.value.reportsRemoved).toBe(0); + } + }, + ); + + testWithQueue()( + // Regression: iterator must include Bull's `active` state so the job + // a reviewer is currently viewing isn't skipped. + 'scrubs a job that is currently locked by a reviewer (active state)', + async ({ org, user, queue, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + const good = { typeId: 'user_type', id: 'good' }; + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: good, + reason: 'legit', + }, + ], + }); + + // Move job into `active`, as if a reviewer opened it. + const dequeued = await mrtService.dequeueNextJob({ + orgId: org.id, + queueId: queue.id, + userId: user.id, + }); + expect(dequeued).toBeTruthy(); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + }); + + // Job is locked → can't remove, so we scrub in place. + expect(result.reportsRemoved).toBe(1); + expect(result.jobsDeleted).toBe(0); + expect(result.jobsScrubbed).toBe(1); + }, + ); + + testWithQueue()( + 'scopes the sweep to a single job when jobId is provided', + async ({ org, user, mrtService, addJob }) => { + const bad = { typeId: 'user_type', id: 'bad' }; + const targetJob = await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + // A second job with a report from the same bad reporter that must + // be left untouched. + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: bad, + reason: 'fake', + }, + ], + }); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: bad, + invokedBy: invoker(org.id, user.id), + jobId: targetJob.id, + }); + + expect(result.queuesScanned).toBe(1); + expect(result.jobsScanned).toBe(1); + expect(result.reportsRemoved).toBe(1); + // Target job was REPORT-only -> deleted; the second job survives. + expect(result.jobsDeleted).toBe(1); + expect(result.jobsScrubbed).toBe(0); + }, + ); + + testWithQueue()( + 'returns zeros when jobId refers to a job that no longer exists', + async ({ org, user, mrtService }) => { + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: { typeId: 'user_type', id: 'bad' }, + invokedBy: invoker(org.id, user.id), + jobId: 'unknown-job-id', + }); + + expect(result.queuesScanned).toBe(0); + expect(result.jobsScanned).toBe(0); + expect(result.reportsRemoved).toBe(0); + expect(result.jobsDeleted).toBe(0); + expect(result.jobsScrubbed).toBe(0); + }, + ); + + testWithQueue()( + 'is a no-op when the reporter has no reports in the org', + async ({ org, user, mrtService, addJob }) => { + await addJob({ + itemId: uuidv1(), + reportHistory: [ + { + reportId: uuidv1(), + reportedAt: new Date(), + reporterId: { typeId: 'user_type', id: 'someone_else' }, + reason: 'spam', + }, + ], + }); + + const result = await mrtService.invalidateReportsFromReporter({ + orgId: org.id, + reporter: { typeId: 'user_type', id: 'bad' }, + invokedBy: invoker(org.id, user.id), + }); + + expect(result.jobsScrubbed).toBe(0); + expect(result.jobsDeleted).toBe(0); + expect(result.reportsRemoved).toBe(0); + expect(result.jobsScanned).toBe(1); + }, + ); +}); diff --git a/server/services/manualReviewToolService/modules/ReporterInvalidation.ts b/server/services/manualReviewToolService/modules/ReporterInvalidation.ts new file mode 100644 index 0000000..b083b55 --- /dev/null +++ b/server/services/manualReviewToolService/modules/ReporterInvalidation.ts @@ -0,0 +1,371 @@ +import { type ItemIdentifier } from '@roostorg/types'; + +import { type Dependencies } from '../../../iocContainer/index.js'; +import { jsonStringify } from '../../../utils/encoding.js'; +import { instantiateOpaqueType } from '../../../utils/typescript-types.js'; +import { type Invoker } from '../../userManagementService/index.js'; +import { + type JobId, + type ManualReviewJob, + type ManualReviewJobPayload, + type ReportHistory, +} from '../manualReviewToolService.js'; +import type QueueOperations from './QueueOperations.js'; + +export type InvalidateReportsFromReporterInput = { + orgId: string; + reporter: ItemIdentifier; + invokedBy: Invoker; + reason?: string; + // When set, scope the sweep to this single MRT job instead of the org. + jobId?: string; + // Per-queue scan caps; overridable for tests and ops. + batchSize?: number; + maxJobsPerQueue?: number; +}; + +export type InvalidateReportsFromReporterResult = { + queuesScanned: number; + jobsScanned: number; + jobsScrubbed: number; + jobsDeleted: number; + reportsRemoved: number; + // True when a queue exceeded the per-queue scan cap, so the sweep was partial. + truncated: boolean; +}; + +// Keep a single sweep bounded even for a reporter with a huge item set. +const DEFAULT_BATCH_SIZE = 200; +const DEFAULT_MAX_JOBS_PER_QUEUE = 10_000; + +// Org-wide sweeps currently running, keyed by (org, reporter). Guards +// against a reviewer queueing many concurrent sweeps. Process-local, so +// the cap is per pod rather than global. +const IN_FLIGHT_SWEEPS = new Set(); + +function inFlightKey(orgId: string, reporter: ItemIdentifier): string { + return `${orgId}\u241F${reporter.typeId}\u241F${reporter.id}`; +} + +class SweepAlreadyInFlightError extends Error { + readonly code = 'SWEEP_IN_FLIGHT'; + constructor() { + super('A reporter invalidation sweep is already running for this user.'); + this.name = 'SweepAlreadyInFlightError'; + } +} + +export { SweepAlreadyInFlightError }; + +/** + * "Invalidate reports from a reporter" (#404). Walks every pending MRT job + * for the org, removes that reporter's entries from `reportHistory` and + * `reportedForReasons`, and deletes jobs whose history empties out *and* + * which were enqueued purely by a report (kind === 'REPORT'); rule/ + * post-action enqueues are preserved. + */ +export default class ReporterInvalidation { + constructor( + private readonly queueOps: QueueOperations, + private readonly tracer: Dependencies['Tracer'], + ) {} + + async invalidateReportsFromReporter( + input: InvalidateReportsFromReporterInput, + ): Promise { + const { orgId, reporter, invokedBy, jobId, reason } = input; + + return this.tracer.addActiveSpan( + { + resource: 'mrtService', + operation: 'invalidateReportsFromReporter', + attributes: { + 'reporterInvalidation.orgId': orgId, + // Encoded so PII-shaped ids don't end up as raw free strings in + // trace UIs. + 'reporterInvalidation.reporter': jsonStringify({ + typeId: reporter.typeId, + id: reporter.id, + }), + 'reporterInvalidation.invokedByUserId': invokedBy.userId, + 'reporterInvalidation.scope': jobId ? 'single_job' : 'org_wide', + ...(reason == null ? {} : { 'reporterInvalidation.reason': reason }), + }, + }, + async (span) => { + const result: InvalidateReportsFromReporterResult = { + queuesScanned: 0, + jobsScanned: 0, + jobsScrubbed: 0, + jobsDeleted: 0, + reportsRemoved: 0, + truncated: false, + }; + + // Only guard the org-wide path; single-job sweeps are cheap. + const sweepKey = jobId == null ? inFlightKey(orgId, reporter) : null; + if (sweepKey != null) { + if (IN_FLIGHT_SWEEPS.has(sweepKey)) { + throw new SweepAlreadyInFlightError(); + } + IN_FLIGHT_SWEEPS.add(sweepKey); + } + + try { + if (jobId != null) { + const located = await this.queueOps.findPendingJobByJobId({ + orgId, + jobId: instantiateOpaqueType(jobId), + }); + result.queuesScanned = located ? 1 : 0; + if (located) { + result.jobsScanned = 1; + const perJob = await this.#applyScrubToJob({ + orgId, + queueId: located.queueId, + job: located.job, + reporter, + invokerUserId: invokedBy.userId, + }); + result.jobsScrubbed += perJob.jobsScrubbed; + result.jobsDeleted += perJob.jobsDeleted; + result.reportsRemoved += perJob.reportsRemoved; + } + } else { + const queues = + await this.queueOps.getAllQueuesForOrgAndDangerouslyBypassPermissioning( + orgId, + ); + result.queuesScanned = queues.length; + + // Appeals queues don't carry user-submitted reportHistory. + const nonAppealQueues = queues.filter((q) => !q.isAppealsQueue); + + for (const queue of nonAppealQueues) { + const perQueue = await this.#scrubQueueForReporter({ + orgId, + queueId: queue.id, + reporter, + invokerUserId: invokedBy.userId, + batchSize: input.batchSize ?? DEFAULT_BATCH_SIZE, + maxJobsPerQueue: + input.maxJobsPerQueue ?? DEFAULT_MAX_JOBS_PER_QUEUE, + }); + + result.jobsScanned += perQueue.jobsScanned; + result.jobsScrubbed += perQueue.jobsScrubbed; + result.jobsDeleted += perQueue.jobsDeleted; + result.reportsRemoved += perQueue.reportsRemoved; + result.truncated ||= perQueue.truncated; + } + } + } finally { + if (sweepKey != null) { + IN_FLIGHT_SWEEPS.delete(sweepKey); + } + } + + span.setAttributes({ + 'reporterInvalidation.jobsScanned': result.jobsScanned, + 'reporterInvalidation.jobsScrubbed': result.jobsScrubbed, + 'reporterInvalidation.jobsDeleted': result.jobsDeleted, + 'reporterInvalidation.reportsRemoved': result.reportsRemoved, + 'reporterInvalidation.truncated': result.truncated, + }); + + return result; + }, + ); + } + + async #scrubQueueForReporter(opts: { + orgId: string; + queueId: string; + reporter: ItemIdentifier; + invokerUserId: string; + batchSize: number; + maxJobsPerQueue: number; + }): Promise<{ + jobsScanned: number; + jobsScrubbed: number; + jobsDeleted: number; + reportsRemoved: number; + truncated: boolean; + }> { + const { + orgId, + queueId, + reporter, + invokerUserId, + batchSize, + maxJobsPerQueue, + } = opts; + + let jobsScanned = 0; + let jobsScrubbed = 0; + let jobsDeleted = 0; + let reportsRemoved = 0; + const progress = { truncated: false }; + + for await (const job of this.queueOps.iteratePendingJobsForQueue({ + orgId, + queueId, + batchSize, + maxJobs: maxJobsPerQueue, + progress, + })) { + jobsScanned++; + const perJob = await this.#applyScrubToJob({ + orgId, + queueId, + job, + reporter, + invokerUserId, + }); + jobsScrubbed += perJob.jobsScrubbed; + jobsDeleted += perJob.jobsDeleted; + reportsRemoved += perJob.reportsRemoved; + } + + return { + jobsScanned, + jobsScrubbed, + jobsDeleted, + reportsRemoved, + truncated: progress.truncated, + }; + } + + async #applyScrubToJob(opts: { + orgId: string; + queueId: string; + job: ManualReviewJob; + reporter: ItemIdentifier; + invokerUserId: string; + }): Promise<{ + jobsScrubbed: number; + jobsDeleted: number; + reportsRemoved: number; + }> { + const { orgId, queueId, job, reporter, invokerUserId } = opts; + + const scrub = scrubPayloadForReporter(job.payload, reporter); + if (scrub.removedCount === 0) { + return { jobsScrubbed: 0, jobsDeleted: 0, reportsRemoved: 0 }; + } + + // Delete only when the job was enqueued purely by a report and its + // history is now empty. `removeJobAllowingInvokerLock` removes the job + // when the invoker holds the lock (or it's unlocked) and scrubs in + // place otherwise, so another reviewer's lock is never stolen. + if ( + scrub.payload.reportHistory.length === 0 && + job.enqueueSourceInfo?.kind === 'REPORT' + ) { + const removed = await this.queueOps.removeJobAllowingInvokerLock({ + orgId, + queueId, + jobId: job.id, + invokerUserId, + }); + if (removed) { + return { + jobsScrubbed: 0, + jobsDeleted: 1, + reportsRemoved: scrub.removedCount, + }; + } + } + + const updated = await this.queueOps.updateJobForQueue({ + orgId, + queueId, + jobId: job.id, + data: { ...job, payload: scrub.payload }, + }); + // undefined means the slot now holds a different job; treat as no-op. + if (updated == null) { + return { jobsScrubbed: 0, jobsDeleted: 0, reportsRemoved: 0 }; + } + return { + jobsScrubbed: 1, + jobsDeleted: 0, + reportsRemoved: scrub.removedCount, + }; + } +} + +/** + * Returns a new payload with the given reporter's entries stripped from + * `reportHistory` and (for DEFAULT-kind payloads) `reportedForReasons`. + * Pure; exported for unit testing. + */ +export function scrubPayloadForReporter( + payload: ManualReviewJobPayload, + reporter: ItemIdentifier, +): { payload: ManualReviewJobPayload; removedCount: number } { + const isMatch = (rid: ItemIdentifier | undefined): boolean => + rid != null && rid.typeId === reporter.typeId && rid.id === reporter.id; + + const filteredHistory: ReportHistory = payload.reportHistory.filter( + (entry) => !isMatch(entry.reporterId), + ); + const historyRemovedCount = + payload.reportHistory.length - filteredHistory.length; + + // Legacy jobs can have an empty `reportHistory` with the reporter still in + // `reportedForReasons`, so check both rather than early-returning on history. + if (payload.kind === 'DEFAULT' && 'reportedForReasons' in payload) { + const existingReasons = payload.reportedForReasons ?? []; + const filteredReasons = existingReasons.filter( + (entry) => !isMatch(entry.reporterId), + ); + const reasonsRemovedCount = existingReasons.length - filteredReasons.length; + + if (historyRemovedCount === 0 && reasonsRemovedCount === 0) { + return { payload, removedCount: 0 }; + } + + // The MRT UI hides `reportHistory[0]` from the "other reports" table, + // assuming it's represented in `reportedForReasons`. If filtering + // empties `reportedForReasons` while history remains, repoint it at + // the new newest entry so that report stays visible. + const reportedForReasons = + filteredReasons.length === 0 && filteredHistory.length > 0 + ? [ + { + reporterId: filteredHistory[0].reporterId, + reason: filteredHistory[0].reason, + }, + ] + : filteredReasons; + + // Keep legacy singular fields in sync when they named the scrubbed reporter. + const legacyReporterMatches = isMatch(payload.reporterIdentifier); + + return { + payload: { + ...payload, + reportHistory: filteredHistory, + reportedForReasons, + ...(legacyReporterMatches && 'reporterIdentifier' in payload + ? { reporterIdentifier: filteredHistory[0]?.reporterId } + : {}), + ...(legacyReporterMatches && 'reportedForReason' in payload + ? { reportedForReason: filteredHistory[0]?.reason } + : {}), + }, + // Fall back to the reasons count for legacy jobs with no history. + removedCount: historyRemovedCount || reasonsRemovedCount, + }; + } + + // NCMEC and other non-DEFAULT payloads only have `reportHistory`. + if (historyRemovedCount === 0) { + return { payload, removedCount: 0 }; + } + return { + payload: { ...payload, reportHistory: filteredHistory }, + removedCount: historyRemovedCount, + }; +}