From d97bde6de3e515fa97d7e463cdb762347030f10b Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Mon, 8 Jun 2026 22:29:11 -0500 Subject: [PATCH] [NCMEC] Allow to configure min images to review for NCMEC report (#710) * [NCMEC] Allow to configure min images to review for NCMEC report * prevent fractional numbers for min --- client/src/graphql/generated.ts | 145 ++++++++++++++++++ .../v2/ncmec/NCMECActions.tsx | 17 +- .../v2/ncmec/NCMECReviewUser.tsx | 49 +++++- .../src/webpages/settings/NCMECSettings.tsx | 109 ++++++++++++- ...view_requirement_to_ncmec_org_settings.sql | 26 ++++ docs/user/child-safety.md | 9 +- server/graphql/generated.ts | 49 ++++++ server/graphql/modules/ncmec.resolver.test.ts | 142 +++++++++++++++++ server/graphql/modules/ncmec.ts | 80 ++++------ .../modules/ncmecOrgSettingsValidation.ts | 101 ++++++++++++ server/graphql/modules/org.ts | 31 ++++ server/services/ncmecService/dbTypes.ts | 2 + server/services/ncmecService/ncmecService.ts | 8 + 13 files changed, 703 insertions(+), 65 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.06.03T04.19.05.add_media_review_requirement_to_ncmec_org_settings.sql create mode 100644 server/graphql/modules/ncmec.resolver.test.ts create mode 100644 server/graphql/modules/ncmecOrgSettingsValidation.ts diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 49d6992..089c4de 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -3060,6 +3060,19 @@ export type GQLNcmecMediaInput = { readonly url: Scalars['String']['input']; }; +/** + * How much media a reviewer must classify before an NCMEC report can be sent. + * ALL requires every piece of media on the account to be reviewed (the original + * behaviour); MINIMUM only requires `minMediaToReview` items, so reviewers + * don't have to classify hundreds of items to submit a report. + */ +export const GQLNcmecMediaReviewRequirement = { + All: 'ALL', + Minimum: 'MINIMUM', +} as const; + +export type GQLNcmecMediaReviewRequirement = + (typeof GQLNcmecMediaReviewRequirement)[keyof typeof GQLNcmecMediaReviewRequirement]; export type GQLNcmecOrgSettings = { readonly __typename: 'NcmecOrgSettings'; readonly companyTemplate?: Maybe; @@ -3071,6 +3084,8 @@ export type GQLNcmecOrgSettings = { readonly defaultInternetDetailType?: Maybe; readonly defaultNcmecQueueId?: Maybe; readonly legalUrl?: Maybe; + readonly mediaReviewRequirement?: Maybe; + readonly minMediaToReview?: Maybe; readonly moreInfoUrl?: Maybe; readonly ncmecAdditionalInfoEndpoint?: Maybe; readonly ncmecPreservationEndpoint?: Maybe; @@ -3089,6 +3104,8 @@ export type GQLNcmecOrgSettingsInput = { readonly defaultInternetDetailType?: InputMaybe; readonly defaultNcmecQueueId?: InputMaybe; readonly legalUrl?: InputMaybe; + readonly mediaReviewRequirement?: InputMaybe; + readonly minMediaToReview?: InputMaybe; readonly moreInfoUrl?: InputMaybe; readonly ncmecAdditionalInfoEndpoint?: InputMaybe; readonly ncmecPreservationEndpoint?: InputMaybe; @@ -3190,6 +3207,17 @@ export type GQLOrg = { readonly itemTypes: ReadonlyArray; readonly mrtQueues: ReadonlyArray; readonly name: Scalars['String']['output']; + /** + * How much media a reviewer must classify before they can send an NCMEC + * report for this org. Readable by any org member (not just MANAGE_ORG) so the + * NCMEC review UI can enforce the policy. Defaults to ALL when unset. + */ + readonly ncmecMediaReviewRequirement: GQLNcmecMediaReviewRequirement; + /** + * Minimum number of media items that must be reviewed before sending an NCMEC + * report when ncmecMediaReviewRequirement is MINIMUM. Defaults to 1. + */ + readonly ncmecMinMediaToReview: Scalars['Int']['output']; readonly ncmecReports: ReadonlyArray; readonly onCallAlertEmail?: Maybe; readonly partialItemsEndpoint?: Maybe; @@ -16802,6 +16830,19 @@ export type GQLGetLatestUserSubmittedItemsWithThreadsQuery = { }>; }; +export type GQLNcmecMediaReviewPolicyQueryVariables = Exact<{ + [key: string]: never; +}>; + +export type GQLNcmecMediaReviewPolicyQuery = { + readonly __typename: 'Query'; + readonly myOrg?: { + readonly __typename: 'Org'; + readonly ncmecMediaReviewRequirement: GQLNcmecMediaReviewRequirement; + readonly ncmecMinMediaToReview: number; + } | null; +}; + export type GQLGetMoreInfoForThreadItemsQueryVariables = Exact<{ ids: ReadonlyArray | GQLItemIdentifierInput; }>; @@ -24608,6 +24649,8 @@ export type GQLNcmecOrgSettingsQuery = { readonly contactPersonFirstName?: string | null; readonly contactPersonLastName?: string | null; readonly contactPersonPhone?: string | null; + readonly mediaReviewRequirement?: GQLNcmecMediaReviewRequirement | null; + readonly minMediaToReview?: number | null; } | null; readonly myOrg?: { readonly __typename: 'Org'; @@ -35302,6 +35345,105 @@ export type GQLGetLatestUserSubmittedItemsWithThreadsQueryResult = GQLGetLatestUserSubmittedItemsWithThreadsQuery, GQLGetLatestUserSubmittedItemsWithThreadsQueryVariables >; +export const GQLNcmecMediaReviewPolicyDocument = gql` + query NcmecMediaReviewPolicy { + myOrg { + ncmecMediaReviewRequirement + ncmecMinMediaToReview + } + } +`; + +/** + * __useGQLNcmecMediaReviewPolicyQuery__ + * + * To run a query within a React component, call `useGQLNcmecMediaReviewPolicyQuery` and pass it any options that fit your needs. + * When your component renders, `useGQLNcmecMediaReviewPolicyQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGQLNcmecMediaReviewPolicyQuery({ + * variables: { + * }, + * }); + */ +export function useGQLNcmecMediaReviewPolicyQuery( + baseOptions?: Apollo.QueryHookOptions< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >(GQLNcmecMediaReviewPolicyDocument, options); +} +export function useGQLNcmecMediaReviewPolicyLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >(GQLNcmecMediaReviewPolicyDocument, options); +} +// @ts-ignore +export function useGQLNcmecMediaReviewPolicySuspenseQuery( + baseOptions?: Apollo.SuspenseQueryHookOptions< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables +>; +export function useGQLNcmecMediaReviewPolicySuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >, +): Apollo.UseSuspenseQueryResult< + GQLNcmecMediaReviewPolicyQuery | undefined, + GQLNcmecMediaReviewPolicyQueryVariables +>; +export function useGQLNcmecMediaReviewPolicySuspenseQuery( + baseOptions?: + | Apollo.SkipToken + | Apollo.SuspenseQueryHookOptions< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >, +) { + const options = + baseOptions === Apollo.skipToken + ? baseOptions + : { ...defaultOptions, ...baseOptions }; + return Apollo.useSuspenseQuery< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables + >(GQLNcmecMediaReviewPolicyDocument, options); +} +export type GQLNcmecMediaReviewPolicyQueryHookResult = ReturnType< + typeof useGQLNcmecMediaReviewPolicyQuery +>; +export type GQLNcmecMediaReviewPolicyLazyQueryHookResult = ReturnType< + typeof useGQLNcmecMediaReviewPolicyLazyQuery +>; +export type GQLNcmecMediaReviewPolicySuspenseQueryHookResult = ReturnType< + typeof useGQLNcmecMediaReviewPolicySuspenseQuery +>; +export type GQLNcmecMediaReviewPolicyQueryResult = Apollo.QueryResult< + GQLNcmecMediaReviewPolicyQuery, + GQLNcmecMediaReviewPolicyQueryVariables +>; export const GQLGetMoreInfoForThreadItemsDocument = gql` query getMoreInfoForThreadItems($ids: [ItemIdentifierInput!]!) { partialItems(input: $ids) { @@ -43238,6 +43380,8 @@ export const GQLNcmecOrgSettingsDocument = gql` contactPersonFirstName contactPersonLastName contactPersonPhone + mediaReviewRequirement + minMediaToReview } myOrg { hasNCMECReportingEnabled @@ -44703,6 +44847,7 @@ export const namedOperations = { AllManualReviewQueues: 'AllManualReviewQueues', getLatestUserSubmittedItemsWithThreads: 'getLatestUserSubmittedItemsWithThreads', + NcmecMediaReviewPolicy: 'NcmecMediaReviewPolicy', getMoreInfoForThreadItems: 'getMoreInfoForThreadItems', getMoreInfoForPartialItems: 'getMoreInfoForPartialItems', getExistingJobsForItem: 'getExistingJobsForItem', diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECActions.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECActions.tsx index 1e07afb..84940fe 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECActions.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECActions.tsx @@ -37,7 +37,8 @@ export default function NCMECActions(props: { setSendReportModalVisible: (visible: boolean) => void; setDeselectAndIgnoreModalVisible: (visible: boolean) => void; isAnyMediaSelected: boolean; - isAllMediaSelected: boolean; + canSendReport: boolean; + sendDisabledReason: string; submitDecision: (input: GQLDecisionSubmission) => void; moveToQueueMenuVisible: boolean; setMoveToQueueMenuVisible: (visible: boolean) => void; @@ -48,7 +49,8 @@ export default function NCMECActions(props: { setSendReportModalVisible, setDeselectAndIgnoreModalVisible, isAnyMediaSelected, - isAllMediaSelected, + canSendReport, + sendDisabledReason, submitDecision, moveToQueueMenuVisible, setMoveToQueueMenuVisible, @@ -78,7 +80,7 @@ export default function NCMECActions(props: { if (disableKeyboardShortcuts) { return; } - if (isAllMediaSelected && event.key === 'Enter') { + if (canSendReport && event.key === 'Enter') { setSendReportModalVisible(true); } }; @@ -91,7 +93,7 @@ export default function NCMECActions(props: { window.removeEventListener('keydown', handleKeyPress); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAllMediaSelected, disableKeyboardShortcuts]); + }, [canSendReport, disableKeyboardShortcuts]); const onMoveToDifferentQueue = useCallback( (newQueueId: string) => { @@ -170,7 +172,7 @@ export default function NCMECActions(props: { const button = useCallback( (decision: NCMECDecisionType | SkipDecisionType) => { - const isDisabled = decision === 'Send' && !isAllMediaSelected; + const isDisabled = decision === 'Send' && !canSendReport; const button = (
{button} @@ -238,7 +240,8 @@ export default function NCMECActions(props: { }, [ data?.myOrg?.mrtQueues, - isAllMediaSelected, + canSendReport, + sendDisabledReason, loading, moveToQueueMenuVisible, onClick, diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx index 74ce6e3..7300d31 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx @@ -21,9 +21,11 @@ import { GQLNcmecFileAnnotation, GQLNcmecIncidentType, GQLNcmecIndustryClassification, + GQLNcmecMediaReviewRequirement, GQLNcmecThreadInput, GQLThreadItem, useGQLGetMoreInfoForThreadItemsQuery, + useGQLNcmecMediaReviewPolicyQuery, useGQLPersonalSafetySettingsQuery, } from '../../../../../../graphql/generated'; import { filterNullOrUndefined } from '../../../../../../utils/collections'; @@ -70,6 +72,15 @@ type NCMECJobPayloadQueryResult = Extract< export type NCMECMediaQueryResult = NCMECJobPayloadQueryResult['allMediaItems'][0]; +gql` + query NcmecMediaReviewPolicy { + myOrg { + ncmecMediaReviewRequirement + ncmecMinMediaToReview + } + } +`; + gql` query getMoreInfoForThreadItems($ids: [ItemIdentifierInput!]!) { partialItems(input: $ids) { @@ -378,6 +389,12 @@ export default function NCMECReviewUser( const navigate = useNavigate(); const { loading, data } = useGQLPersonalSafetySettingsQuery(); + const { data: mediaReviewPolicyData } = useGQLNcmecMediaReviewPolicyQuery(); + const mediaReviewRequirement = + mediaReviewPolicyData?.myOrg?.ncmecMediaReviewRequirement ?? + GQLNcmecMediaReviewRequirement.All; + const mediaReviewMinToReview = + mediaReviewPolicyData?.myOrg?.ncmecMinMediaToReview ?? 1; const noValidMedia = (
@@ -873,6 +890,33 @@ export default function NCMECReviewUser( mediaInDetailViewItem?.__typename === 'ContentItem' ? getFieldValueForRole(mediaInDetailViewItem, 'threadId') : undefined; + + // "None" counts as reviewed but not reported. + const reviewedCount = selectedMedia.length; + const reportedCount = selectedMedia.filter( + (media) => media.category !== 'None', + ).length; + // Cap the minimum at the job's media count so it's always achievable. + const effectiveMinToReview = Math.min( + Math.max(mediaReviewMinToReview, 1), + allMediaItemsWithUrls.length, + ); + const reviewThresholdMet = + mediaReviewRequirement === GQLNcmecMediaReviewRequirement.Minimum + ? reviewedCount >= effectiveMinToReview + : reviewedCount === allMediaItemsWithUrls.length; + // A report can't be empty: require at least one reported item. + const canSendReport = reviewThresholdMet && reportedCount > 0; + const sendDisabledReason = !reviewThresholdMet + ? mediaReviewRequirement === GQLNcmecMediaReviewRequirement.Minimum + ? `Please review at least ${effectiveMinToReview} ${ + effectiveMinToReview === 1 ? 'piece' : 'pieces' + } of media before sending a report to NCMEC.` + : 'Please make a decision on every piece of media in this job before sending a report to NCMEC.' + : reportedCount === 0 + ? 'Select a category for at least one piece of media to include it in the report before sending to NCMEC.' + : ''; + return (
0} - isAllMediaSelected={ - selectedMedia.length === allMediaItemsWithUrls.length - } + canSendReport={canSendReport} + sendDisabledReason={sendDisabledReason} submitDecision={props.submitDecision} moveToQueueMenuVisible={moveToQueueMenuVisible} setMoveToQueueMenuVisible={setMoveToQueueMenuVisible} diff --git a/client/src/webpages/settings/NCMECSettings.tsx b/client/src/webpages/settings/NCMECSettings.tsx index 23e7699..1fd5012 100644 --- a/client/src/webpages/settings/NCMECSettings.tsx +++ b/client/src/webpages/settings/NCMECSettings.tsx @@ -11,6 +11,7 @@ import { import { toast } from '@/coop-ui/Toast'; import { Heading, Text } from '@/coop-ui/Typography'; import { + GQLNcmecMediaReviewRequirement, GQLUserPermission, useGQLNcmecOrgSettingsQuery, useGQLUpdateNcmecOrgSettingsMutation, @@ -45,6 +46,8 @@ gql` contactPersonFirstName contactPersonLastName contactPersonPhone + mediaReviewRequirement + minMediaToReview } myOrg { hasNCMECReportingEnabled @@ -78,6 +81,8 @@ type NcmecSettings = { contactPersonFirstName: string; contactPersonLastName: string; contactPersonPhone: string; + mediaReviewRequirement: GQLNcmecMediaReviewRequirement; + minMediaToReview: string; }; export default function NCMECSettings() { @@ -97,9 +102,13 @@ export default function NCMECSettings() { contactPersonFirstName: '', contactPersonLastName: '', contactPersonPhone: '', + mediaReviewRequirement: GQLNcmecMediaReviewRequirement.All, + minMediaToReview: '1', }); - const { loading, error, data } = useGQLNcmecOrgSettingsQuery({ errorPolicy: 'all' }); + const { loading, error, data } = useGQLNcmecOrgSettingsQuery({ + errorPolicy: 'all', + }); const [updateSettings, { loading: isUpdateLoading }] = useGQLUpdateNcmecOrgSettingsMutation({ @@ -136,6 +145,10 @@ export default function NCMECSettings() { contactPersonLastName: data.ncmecOrgSettings.contactPersonLastName ?? '', contactPersonPhone: data.ncmecOrgSettings.contactPersonPhone ?? '', + mediaReviewRequirement: + data.ncmecOrgSettings.mediaReviewRequirement ?? + GQLNcmecMediaReviewRequirement.All, + minMediaToReview: String(data.ncmecOrgSettings.minMediaToReview ?? 1), }); } }, [data?.ncmecOrgSettings]); @@ -145,7 +158,10 @@ export default function NCMECSettings() { } const permissions = data?.me?.permissions; - if (!permissions || !userHasPermissions(permissions, [GQLUserPermission.ManageOrg])) { + if ( + !permissions || + !userHasPermissions(permissions, [GQLUserPermission.ManageOrg]) + ) { return ; } @@ -184,6 +200,20 @@ export default function NCMECSettings() { return; } + const isMinimumPolicy = + settings.mediaReviewRequirement === + GQLNcmecMediaReviewRequirement.Minimum; + const parsedMinMedia = Number(settings.minMediaToReview); + if ( + isMinimumPolicy && + (!Number.isInteger(parsedMinMedia) || parsedMinMedia < 1) + ) { + toast.error( + 'Minimum media to review must be a whole number of at least 1.', + ); + return; + } + updateSettings({ variables: { input: { @@ -205,6 +235,8 @@ export default function NCMECSettings() { contactPersonFirstName: settings.contactPersonFirstName || null, contactPersonLastName: settings.contactPersonLastName || null, contactPersonPhone: settings.contactPersonPhone || null, + mediaReviewRequirement: settings.mediaReviewRequirement, + minMediaToReview: isMinimumPolicy ? parsedMinMedia : null, }, }, }); @@ -416,6 +448,79 @@ export default function NCMECSettings() { />
+
+ + + + Controls how much media a reviewer must classify before they can + send an NCMEC report. "Review all media" requires a + decision on every item on the account (which can be hundreds); + "minimum" only requires the number below, so reviewers + can report the relevant media without classifying everything. + + {settings.mediaReviewRequirement === + GQLNcmecMediaReviewRequirement.Minimum ? ( +
+ + { + const value = e.target.value; + // Whole numbers only — fractional thresholds are invalid. + if (value === '' || /^\d+$/.test(value)) { + setSettings({ + ...settings, + minMediaToReview: value, + }); + } + }} + placeholder="1" + /> + + Reviewers must classify at least this many media items (and + report at least one) before they can submit the report. + +
+ ) : null} +
+