From 86e42979488db6ca33e3a401e4ec8c63a9f2d7f1 Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Mon, 29 Jun 2026 16:46:23 -0400 Subject: [PATCH 1/6] fix: remove gating for non-media NCMEC messages --- .../ncmecService/ncmecEnqueueToMrt.ts | 4 -- .../services/ncmecService/ncmecReporting.ts | 49 ++++++++++++------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/server/services/ncmecService/ncmecEnqueueToMrt.ts b/server/services/ncmecService/ncmecEnqueueToMrt.ts index 7fa771fec..f3488a3c5 100644 --- a/server/services/ncmecService/ncmecEnqueueToMrt.ts +++ b/server/services/ncmecService/ncmecEnqueueToMrt.ts @@ -177,10 +177,6 @@ export default class NcmecEnqueueToMrt { reportedItemType, ); - if (allMediaItems.length === 0) { - return { status: 'SKIPPED' }; - } - // TODO: Write this to a data warehouse table and enqueue based off of a job instead await this.manualReviewToolService.enqueue( { diff --git a/server/services/ncmecService/ncmecReporting.ts b/server/services/ncmecService/ncmecReporting.ts index c68e67e09..3a22d1413 100644 --- a/server/services/ncmecService/ncmecReporting.ts +++ b/server/services/ncmecService/ncmecReporting.ts @@ -512,9 +512,7 @@ export function clampIncidentDateTimeToPast( ): { value: string; wasClamped: boolean } { const maxCreatedAtMs = new Date(maxCreatedAt).getTime(); if (Number.isNaN(maxCreatedAtMs)) { - throw new Error( - `Invalid media createdAt timestamp for incidentDateTime: ${maxCreatedAt}`, - ); + throw new Error(`Invalid timestamp for incidentDateTime: ${maxCreatedAt}`); } const ceilingMs = nowMs - 1000; const wasClamped = maxCreatedAtMs > ceilingMs; @@ -525,6 +523,31 @@ export function clampIncidentDateTimeToPast( }; } +// consolidate timestamp fetching and verification in one function for media and +// threads +export function latestEvidenceTimestamp( + media: readonly { createdAt: string }[], + threads: readonly { + reportedContent: readonly { sentAt: string | Date }[]; + }[], +): string { + const rawTimestamps: (string | Date)[] = [ + ...media.map((m) => m.createdAt), + ...threads.flatMap((t) => t.reportedContent.map((c) => c.sentAt)), + ]; + if (rawTimestamps.length === 0) { + throw new Error('Report has neither media nor messages'); + } + const evidenceTimestampsMs = rawTimestamps.map((raw) => { + const ms = raw instanceof Date ? raw.getTime() : Date.parse(raw); + if (Number.isNaN(ms)) { + throw new Error(`Invalid timestamp for incidentDateTime: ${String(raw)}`); + } + return ms; + }); + return new Date(Math.max(...evidenceTimestampsMs)).toISOString(); +} + /** Build the `ipCaptureEvent` array for an NCMEC person or media block: * webhook events + caller-supplied events + role-IP-synthesised event, in * that order. Returns `undefined` when all sources are empty. */ @@ -1459,22 +1482,10 @@ export default class NcmecReporting { return 'UNSUPPORTED_ORG'; } - if (reportParams.media.length === 0) { - throw new Error('No media in report'); - } - const latestMedia = _.maxBy(reportParams.media, (m) => { - const ms = Date.parse(m.createdAt); - if (Number.isNaN(ms)) { - throw new Error( - `Invalid media createdAt timestamp for incidentDateTime: ${m.createdAt}`, - ); - } - return ms; - }); - if (latestMedia === undefined) { - throw new Error('No media in report'); - } - const maxCreatedAt = latestMedia.createdAt; + const maxCreatedAt = latestEvidenceTimestamp( + reportParams.media, + reportParams.threads, + ); const { value: clampedIncidentDateTime, wasClamped } = clampIncidentDateTimeToPast(maxCreatedAt); From 03e0da97c1b038d65465c603562fc38a7c85102e Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Mon, 29 Jun 2026 16:48:32 -0400 Subject: [PATCH 2/6] test: add tests for latestEvidenceTimestamp helper func --- .../ncmecService/ncmecReporting.test.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/server/services/ncmecService/ncmecReporting.test.ts b/server/services/ncmecService/ncmecReporting.test.ts index 9ab504072..1da455eef 100644 --- a/server/services/ncmecService/ncmecReporting.test.ts +++ b/server/services/ncmecService/ncmecReporting.test.ts @@ -1,6 +1,7 @@ import { buildInternetDetailsFromOrgSetting, clampIncidentDateTimeToPast, + latestEvidenceTimestamp, mergeFieldRoleIpIntoEvents, NCMECEvent, resolveReportedPersonEmail, @@ -143,7 +144,71 @@ describe('NCMEC reporting', () => { it('throws on invalid timestamps', () => { expect(() => clampIncidentDateTimeToPast('not-a-date', NOW_MS)).toThrow( - /Invalid media createdAt timestamp/, + /Invalid timestamp for incidentDateTime/, + ); + }); + }); + + describe('latestEvidenceTimestamp', () => { + const media = (createdAt: string) => ({ createdAt }); + const thread = (...sentAts: (string | Date)[]) => ({ + reportedContent: sentAts.map((sentAt) => ({ sentAt })), + }); + + it('returns the most recent media createdAt', () => { + expect( + latestEvidenceTimestamp( + [ + media('2026-01-10T00:00:00.000Z'), + media('2026-01-12T00:00:00.000Z'), + ], + [], + ), + ).toEqual('2026-01-12T00:00:00.000Z'); + }); + + it('derives the timestamp from messages for a text-only report', () => { + expect( + latestEvidenceTimestamp( + [], + [thread('2026-01-05T00:00:00.000Z', '2026-01-08T00:00:00.000Z')], + ), + ).toEqual('2026-01-08T00:00:00.000Z'); + }); + + it('takes the max across both media and messages', () => { + expect( + latestEvidenceTimestamp( + [media('2026-01-12T00:00:00.000Z')], + [thread('2026-01-20T00:00:00.000Z')], + ), + ).toEqual('2026-01-20T00:00:00.000Z'); + }); + + it('accepts Date-valued message timestamps', () => { + expect( + latestEvidenceTimestamp( + [], + [thread(new Date('2026-01-09T00:00:00.000Z'))], + ), + ).toEqual('2026-01-09T00:00:00.000Z'); + }); + + it('throws when there is no evidence at all', () => { + expect(() => latestEvidenceTimestamp([], [])).toThrow( + /Report has neither media nor messages/, + ); + expect(() => latestEvidenceTimestamp([], [thread()])).toThrow( + /Report has neither media nor messages/, + ); + }); + + it('throws on an unparseable media or message timestamp', () => { + expect(() => latestEvidenceTimestamp([media('not-a-date')], [])).toThrow( + /Invalid timestamp for incidentDateTime/, + ); + expect(() => latestEvidenceTimestamp([], [thread('not-a-date')])).toThrow( + /Invalid timestamp for incidentDateTime/, ); }); }); From 5b6c19d6d7e0317f6b610b328d9d710a71fb69ff Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Mon, 29 Jun 2026 23:47:47 -0400 Subject: [PATCH 3/6] fix: nits and review comments --- server/services/ncmecService/dbTypes.ts | 2 +- .../services/ncmecService/ncmecReporting.ts | 25 ++++++++----------- .../ncmecService/ncmecReviewerErrors.ts | 4 +-- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/server/services/ncmecService/dbTypes.ts b/server/services/ncmecService/dbTypes.ts index 8522b6d43..a26c57f7c 100644 --- a/server/services/ncmecService/dbTypes.ts +++ b/server/services/ncmecService/dbTypes.ts @@ -44,7 +44,7 @@ export type NcmecReportingServicePg = { report_id: string; user_id: string; user_item_type_id: string; - reported_media: NonEmptyArray; + reported_media: Array; reviewer_id?: string; created_at: GeneratedAlways; updated_at: GeneratedAlways; diff --git a/server/services/ncmecService/ncmecReporting.ts b/server/services/ncmecService/ncmecReporting.ts index 3a22d1413..5acf0ed69 100644 --- a/server/services/ncmecService/ncmecReporting.ts +++ b/server/services/ncmecService/ncmecReporting.ts @@ -13,10 +13,7 @@ import { type JSONSchemaV4 } from '../../utils/json-schema-types.js'; import { type FixKyselyRowCorrelation } from '../../utils/kysely.js'; import { logErrorJson } from '../../utils/logging.js'; import { assertUnreachable, withRetries } from '../../utils/misc.js'; -import { - type CollapseCases, - type NonEmptyArray, -} from '../../utils/typescript-types.js'; +import { type CollapseCases } from '../../utils/typescript-types.js'; import { rawItemSubmissionToItemSubmission } from '../itemProcessingService/makeItemSubmission.js'; import { type RawItemData } from '../itemProcessingService/toNormalizedItemDataOrErrors.js'; import { @@ -538,14 +535,14 @@ export function latestEvidenceTimestamp( if (rawTimestamps.length === 0) { throw new Error('Report has neither media nor messages'); } - const evidenceTimestampsMs = rawTimestamps.map((raw) => { - const ms = raw instanceof Date ? raw.getTime() : Date.parse(raw); - if (Number.isNaN(ms)) { - throw new Error(`Invalid timestamp for incidentDateTime: ${String(raw)}`); - } - return ms; - }); - return new Date(Math.max(...evidenceTimestampsMs)).toISOString(); + // updated to allow a lenient approach to timestamps. If one fails, it doesn't + // fail the report and fallsback to "now" + const evidenceTimestampsMs = rawTimestamps + .map((raw) => (raw instanceof Date ? raw.getTime() : Date.parse(raw))) + .filter((ms) => !Number.isNaN(ms)); + return evidenceTimestampsMs.length > 0 + ? new Date(Math.max(...evidenceTimestampsMs)).toISOString() + : new Date().toISOString(); } /** Build the `ipCaptureEvent` array for an NCMEC person or media block: @@ -1239,6 +1236,7 @@ export default class NcmecReporting { } if ( + reportedMedia.length > 0 && responseBody.media?.filter( (it) => it.missing === false || it.missing === undefined, ).length === 0 @@ -1809,8 +1807,7 @@ export default class NcmecReporting { user_item_type_id: reportParams.reportedUser.typeId, reviewer_id: reportParams.reviewerId, - // Safe to cast as a non empty array because of the createdAt check above - reported_media: reportedMedia as NonEmptyArray, + reported_media: reportedMedia, report_xml: xml, additional_files: additionalFiles, reported_messages: threadCsvs, diff --git a/server/services/ncmecService/ncmecReviewerErrors.ts b/server/services/ncmecService/ncmecReviewerErrors.ts index 8bf8dcf9a..299f5a111 100644 --- a/server/services/ncmecService/ncmecReviewerErrors.ts +++ b/server/services/ncmecService/ncmecReviewerErrors.ts @@ -17,7 +17,7 @@ const REVIEWER_ERROR_MESSAGES = { // Allowlist of thrown messages that are already operator-friendly. New // throw sites stay opaque until classified explicitly. const ALREADY_REVIEWER_FRIENDLY: ReadonlySet = new Set([ - 'No media in report', + 'Report has neither media nor messages', 'Organization does not have a NCMEC preservation endpoint', 'NCMEC report requires a non-empty reporter contact email; configure it in Settings → NCMEC.', 'escalateToHighPriority must be non-blank when supplied and at most 3000 characters', @@ -34,7 +34,7 @@ const REVIEWER_PREFIX_RULES: readonly { { prefix: 'org id not found', category: 'CONFIG' }, { prefix: 'Unable to find reported media in job payload', category: 'MEDIA' }, { prefix: 'Unable to find item type for reported media', category: 'MEDIA' }, - { prefix: 'Invalid media createdAt timestamp', category: 'MEDIA' }, + { prefix: 'Invalid timestamp for incidentDateTime', category: 'VALIDATION' }, { prefix: 'Cannot download media from', category: 'MEDIA' }, { prefix: 'NCMEC file upload failed', category: 'MEDIA' }, { prefix: 'NCMEC thread CSV upload failed', category: 'MEDIA' }, From 057001059e6b6970f9ba6724b30bae8cfe3e663b Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Mon, 29 Jun 2026 23:48:05 -0400 Subject: [PATCH 4/6] test: updates to tests after nits --- .../ncmecService/ncmecReporting.test.ts | 23 +++++++++++++------ .../ncmecService/ncmecReviewerErrors.test.ts | 14 +++++++++-- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/server/services/ncmecService/ncmecReporting.test.ts b/server/services/ncmecService/ncmecReporting.test.ts index 1da455eef..c4359e996 100644 --- a/server/services/ncmecService/ncmecReporting.test.ts +++ b/server/services/ncmecService/ncmecReporting.test.ts @@ -203,13 +203,22 @@ describe('NCMEC reporting', () => { ); }); - it('throws on an unparseable media or message timestamp', () => { - expect(() => latestEvidenceTimestamp([media('not-a-date')], [])).toThrow( - /Invalid timestamp for incidentDateTime/, - ); - expect(() => latestEvidenceTimestamp([], [thread('not-a-date')])).toThrow( - /Invalid timestamp for incidentDateTime/, - ); + it('skips an unparseable timestamp and uses the latest valid one', () => { + expect( + latestEvidenceTimestamp( + [], + [thread('not-a-date', '2026-01-08T00:00:00.000Z')], + ), + ).toEqual('2026-01-08T00:00:00.000Z'); + }); + + it('falls back to ~now when evidence exists but no timestamp parses', () => { + const before = Date.now(); + const result = latestEvidenceTimestamp([media('not-a-date')], []); + const after = Date.now(); + const ms = Date.parse(result); + expect(ms).toBeGreaterThanOrEqual(before); + expect(ms).toBeLessThanOrEqual(after); }); }); diff --git a/server/services/ncmecService/ncmecReviewerErrors.test.ts b/server/services/ncmecService/ncmecReviewerErrors.test.ts index f521fe520..6fda1b4e0 100644 --- a/server/services/ncmecService/ncmecReviewerErrors.test.ts +++ b/server/services/ncmecService/ncmecReviewerErrors.test.ts @@ -35,8 +35,18 @@ describe('summarizeNcmecErrorForReviewer', () => { it('passes through known reviewer-friendly local errors verbatim', () => { expect( - summarizeNcmecErrorForReviewer(new Error('No media in report')), - ).toBe('No media in report'); + summarizeNcmecErrorForReviewer( + new Error('Report has neither media nor messages'), + ), + ).toBe('Report has neither media nor messages'); + }); + + it('classifies an unparseable incidentDateTime timestamp as validation', () => { + expect( + summarizeNcmecErrorForReviewer( + new Error('Invalid timestamp for incidentDateTime: not-a-date'), + ), + ).toMatch(/failed validation/); }); it('classifies missing-config throws to a config category', () => { From 44d9716880aa2c5bf7fe694395ca62f879f18152 Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Tue, 30 Jun 2026 00:34:12 -0400 Subject: [PATCH 5/6] fix: further timestamp refinements --- .../services/ncmecService/ncmecReporting.test.ts | 14 +++++++------- server/services/ncmecService/ncmecReporting.ts | 11 ++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/services/ncmecService/ncmecReporting.test.ts b/server/services/ncmecService/ncmecReporting.test.ts index c4359e996..34f9326fc 100644 --- a/server/services/ncmecService/ncmecReporting.test.ts +++ b/server/services/ncmecService/ncmecReporting.test.ts @@ -212,13 +212,13 @@ describe('NCMEC reporting', () => { ).toEqual('2026-01-08T00:00:00.000Z'); }); - it('falls back to ~now when evidence exists but no timestamp parses', () => { - const before = Date.now(); - const result = latestEvidenceTimestamp([media('not-a-date')], []); - const after = Date.now(); - const ms = Date.parse(result); - expect(ms).toBeGreaterThanOrEqual(before); - expect(ms).toBeLessThanOrEqual(after); + it('throws when evidence exists but no timestamp parses', () => { + expect(() => latestEvidenceTimestamp([media('not-a-date')], [])).toThrow( + /Invalid timestamp for incidentDateTime/, + ); + expect(() => latestEvidenceTimestamp([], [thread('not-a-date')])).toThrow( + /Invalid timestamp for incidentDateTime/, + ); }); }); diff --git a/server/services/ncmecService/ncmecReporting.ts b/server/services/ncmecService/ncmecReporting.ts index 5acf0ed69..fad9c8b2a 100644 --- a/server/services/ncmecService/ncmecReporting.ts +++ b/server/services/ncmecService/ncmecReporting.ts @@ -535,14 +535,15 @@ export function latestEvidenceTimestamp( if (rawTimestamps.length === 0) { throw new Error('Report has neither media nor messages'); } - // updated to allow a lenient approach to timestamps. If one fails, it doesn't - // fail the report and fallsback to "now" + // updated to allow a lenient approach to timestamps. If none parse, throw an + // error and surface the bad data via the validation error path const evidenceTimestampsMs = rawTimestamps .map((raw) => (raw instanceof Date ? raw.getTime() : Date.parse(raw))) .filter((ms) => !Number.isNaN(ms)); - return evidenceTimestampsMs.length > 0 - ? new Date(Math.max(...evidenceTimestampsMs)).toISOString() - : new Date().toISOString(); + if (evidenceTimestampsMs.length === 0) { + throw new Error('Invalid timestamp for incidentDateTime'); + } + return new Date(Math.max(...evidenceTimestampsMs)).toISOString(); } /** Build the `ipCaptureEvent` array for an NCMEC person or media block: From 230d9bbcba30325854e2d7a3ae0c7310682f2529 Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Tue, 11 Aug 2026 00:46:37 -0400 Subject: [PATCH 6/6] fix formatting --- .../manualReviewToolService/manualReviewToolService.test.ts | 1 - .../services/manualReviewToolService/modules/JobRouting.test.ts | 1 - .../manualReviewToolService/modules/ReporterInvalidation.test.ts | 1 - .../moderationConfigService/moderationConfigService.test.ts | 1 - 4 files changed, 4 deletions(-) diff --git a/server/services/manualReviewToolService/manualReviewToolService.test.ts b/server/services/manualReviewToolService/manualReviewToolService.test.ts index 9d0402ff1..4987de8b2 100644 --- a/server/services/manualReviewToolService/manualReviewToolService.test.ts +++ b/server/services/manualReviewToolService/manualReviewToolService.test.ts @@ -1,4 +1,3 @@ - import { uid } from 'uid'; import { v1 as uuidv1 } from 'uuid'; diff --git a/server/services/manualReviewToolService/modules/JobRouting.test.ts b/server/services/manualReviewToolService/modules/JobRouting.test.ts index fc426673a..eca6eb3dc 100644 --- a/server/services/manualReviewToolService/modules/JobRouting.test.ts +++ b/server/services/manualReviewToolService/modules/JobRouting.test.ts @@ -1,4 +1,3 @@ - import { ScalarTypes } from '@roostorg/coop-types'; import { uid } from 'uid'; diff --git a/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts b/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts index fa0d53b78..e7a745b74 100644 --- a/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts +++ b/server/services/manualReviewToolService/modules/ReporterInvalidation.test.ts @@ -1,4 +1,3 @@ - import { uid } from 'uid'; import { v1 as uuidv1 } from 'uuid'; diff --git a/server/services/moderationConfigService/moderationConfigService.test.ts b/server/services/moderationConfigService/moderationConfigService.test.ts index 3d96c8e08..4447c8e04 100644 --- a/server/services/moderationConfigService/moderationConfigService.test.ts +++ b/server/services/moderationConfigService/moderationConfigService.test.ts @@ -1,4 +1,3 @@ - import { faker } from '@faker-js/faker'; import { Kysely } from 'kysely'; import { type UnionToIntersection } from 'type-fest';