From 61985f9927b850ddee699886a2ba1059df656a44 Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Fri, 15 May 2026 09:18:33 -0500 Subject: [PATCH] Surface users in investigation tool even when no Submitted item available (#444) * Fix #437 Surface users in investigation tool even when no Submitted item available * fix generated code --- client/eslint.config.mjs | 4 + client/src/graphql/generated.ts | 7 + .../investigation/ItemInvestigation.tsx | 32 +- server/graphql/generated.ts | 10 + .../modules/investigation.resolver.test.ts | 250 ++++++++++++++ server/graphql/modules/investigation.ts | 222 ++++++++----- server/graphql/modules/manualReviewTool.ts | 5 + .../ClickhouseActionExecutionsAdapter.test.ts | 143 ++++++++ .../ClickhouseActionExecutionsAdapter.ts | 97 +++++- ...lickhouseContentApiRequestsAdapter.test.ts | 88 +++++ .../ClickhouseContentApiRequestsAdapter.ts | 85 +++-- .../queries/IActionExecutionsAdapter.ts | 17 +- .../queries/IContentApiRequestsAdapter.ts | 20 +- .../itemInvestigationServiceAdapter.ts | 52 ++- ...esizeUserItemFromCreatorReferences.test.ts | 310 ++++++++++++++++++ ...synthesizeUserItemFromCreatorReferences.ts | 155 +++++++++ server/utils/time.ts | 1 + 17 files changed, 1372 insertions(+), 126 deletions(-) create mode 100644 server/graphql/modules/investigation.resolver.test.ts create mode 100644 server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.test.ts create mode 100644 server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.test.ts create mode 100644 server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.test.ts create mode 100644 server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.ts diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 22dd763..18e27ef 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -23,6 +23,10 @@ export default [ '**/*.stories.tsx', 'vite.config.ts', 'vite-env.d.ts', + // Build output (gitignored) and tooling config not in tsconfig. + 'build/**', + '.storybook/**', + 'postcss.config.js', ], }, ...flatConfigs.map((config) => diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index b437651..aeea72e 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -1587,6 +1587,11 @@ export type GQLItemInput = { export type GQLItemSubmissions = { readonly __typename: 'ItemSubmissions'; + /** + * True when this item was synthesized server-side from indirect references + * rather than a real submission. `latest.data` is empty when set. + */ + readonly isSynthetic?: Maybe; readonly latest: GQLItem; readonly prior?: Maybe>; }; @@ -6763,6 +6768,7 @@ export type GQLGetItemsWithIdQuery = { readonly __typename: 'Query'; readonly itemsWithId: ReadonlyArray<{ readonly __typename: 'ItemSubmissions'; + readonly isSynthetic?: boolean | null; readonly latest: | { readonly __typename: 'ContentItem'; @@ -29756,6 +29762,7 @@ export type GQLGetOrgDataQueryResult = Apollo.QueryResult< export const GQLGetItemsWithIdDocument = gql` query GetItemsWithId($id: ID!, $typeId: ID) { itemsWithId(itemId: $id, typeId: $typeId, returnFirstResultOnly: true) { + isSynthetic latest { ... on ItemBase { id diff --git a/client/src/webpages/dashboard/investigation/ItemInvestigation.tsx b/client/src/webpages/dashboard/investigation/ItemInvestigation.tsx index bc92b1e..d6d4b11 100644 --- a/client/src/webpages/dashboard/investigation/ItemInvestigation.tsx +++ b/client/src/webpages/dashboard/investigation/ItemInvestigation.tsx @@ -1,6 +1,6 @@ import { gql } from '@apollo/client'; import { ItemIdentifier } from '@roostorg/types'; -import { Input } from 'antd'; +import { Alert, Input } from 'antd'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -67,6 +67,7 @@ gql` query GetItemsWithId($id: ID!, $typeId: ID) { itemsWithId(itemId: $id, typeId: $typeId, returnFirstResultOnly: true) { + isSynthetic latest { ... on ItemBase { id @@ -352,9 +353,31 @@ export default function ItemInvestigation(props: { ); } - const item = eligibleItems.find( - (it) => it.id === selectedItem?.id && it.type.id === selectedItem.typeId, - )!; + const selectedWrapper = eligibleItemsResult.find( + (wrapper) => + wrapper.latest.id === selectedItem?.id && + wrapper.latest.type.id === selectedItem.typeId, + ); + if (!selectedWrapper) { + return ( + + ); + } + const item = selectedWrapper.latest; + const isSynthetic = selectedWrapper.isSynthetic === true; + const syntheticBanner = isSynthetic ? ( + + ) : null; const { itemTypes: allItemTypes, @@ -389,6 +412,7 @@ export default function ItemInvestigation(props: { case 'UserItem': return (
+ {syntheticBanner} ; readonly latest: GQLItem; readonly prior?: Maybe>; }; @@ -9380,6 +9385,11 @@ export type GQLItemSubmissionsResolvers< ParentType extends GQLResolversParentTypes['ItemSubmissions'] = GQLResolversParentTypes['ItemSubmissions'], > = { + isSynthetic?: Resolver< + Maybe, + ParentType, + ContextType + >; latest?: Resolver; prior?: Resolver< Maybe>, diff --git a/server/graphql/modules/investigation.resolver.test.ts b/server/graphql/modules/investigation.resolver.test.ts new file mode 100644 index 0000000..10399d2 --- /dev/null +++ b/server/graphql/modules/investigation.resolver.test.ts @@ -0,0 +1,250 @@ +import { + type ItemSubmission, + type NormalizedItemData, + type SubmissionId, +} from '../../services/itemProcessingService/index.js'; +import { + type ItemType, + type UserItemType, +} from '../../services/moderationConfigService/index.js'; +import { instantiateOpaqueType } from '../../utils/typescript-types.js'; +import { + resolveItemsWithId, + type ItemsWithIdContext, +} from './investigation.js'; + +function makeUserType(overrides: Partial = {}): UserItemType { + return { + id: 'user-type-1', + kind: 'USER', + name: 'User', + description: null, + version: '2025-01-01', + schema: [ + { name: 'username', type: 'STRING', required: false, container: null }, + ], + schemaVariant: 'original', + schemaFieldRoles: {}, + orgId: 'org-1', + isDefaultUserType: false, + ...overrides, + }; +} + +function makeSubmission(opts: { + itemId: string; + itemType?: ItemType; +}): ItemSubmission { + return instantiateOpaqueType({ + submissionId: instantiateOpaqueType('sub-1'), + submissionTime: new Date('2026-05-01T00:00:00Z'), + itemId: opts.itemId, + creator: undefined, + data: instantiateOpaqueType({}), + itemType: opts.itemType ?? makeUserType(), + }); +} + +async function* emptyAsyncIterable(): AsyncIterableIterator { + // no-op +} + +async function* singleAsyncIterable(value: T): AsyncIterableIterator { + yield value; +} + +type ItemInvestigationServiceMock = + ItemsWithIdContext['services']['ItemInvestigationService']; + +type SubmissionsForItem = NonNullable< + Awaited> +>; + +function makeContext( + overrides: Partial = {}, + user: { orgId: string } | null = { orgId: 'org-1' }, +) { + const service: ItemInvestigationServiceMock = { + getItemByIdentifier: jest.fn(async () => null), + getItemByTypeAgnosticIdentifier: jest.fn(() => + emptyAsyncIterable(), + ), + synthesizeUserItemFromCreatorReferences: jest.fn(async () => null), + ...overrides, + }; + + const ctx: ItemsWithIdContext = { + getUser: () => user, + services: { ItemInvestigationService: service }, + }; + + return { ctx, service }; +} + +describe('investigation resolvers', () => { + describe('resolveItemsWithId', () => { + it('rejects when unauthenticated', async () => { + const { ctx, service } = makeContext({}, null); + + await expect( + resolveItemsWithId({ itemId: 'i-1' }, ctx), + ).rejects.toMatchObject({ extensions: { code: 'UNAUTHENTICATED' } }); + + expect(service.getItemByIdentifier).not.toHaveBeenCalled(); + expect( + service.synthesizeUserItemFromCreatorReferences, + ).not.toHaveBeenCalled(); + }); + + describe('typed path (typeId provided)', () => { + it('returns the real submission and never falls back to synthesis', async () => { + const submission = makeSubmission({ itemId: 'i-1' }); + const { ctx, service } = makeContext({ + getItemByIdentifier: jest.fn().mockResolvedValue({ + latestSubmission: submission, + priorSubmissions: undefined, + }), + }); + + const result = await resolveItemsWithId( + { itemId: 'i-1', typeId: 'user-type-1' }, + ctx, + ); + + expect(result).toHaveLength(1); + expect(result[0].latest.id).toBe('i-1'); + expect( + service.synthesizeUserItemFromCreatorReferences, + ).not.toHaveBeenCalled(); + }); + + it('falls back to synthesis (with knownUserTypeId) when no real submission exists', async () => { + const synthSubmission = makeSubmission({ itemId: 'i-1' }); + const { ctx, service } = makeContext({ + getItemByIdentifier: jest.fn().mockResolvedValue(null), + synthesizeUserItemFromCreatorReferences: jest.fn().mockResolvedValue({ + latestSubmission: synthSubmission, + priorSubmissions: undefined, + }), + }); + + const result = await resolveItemsWithId( + { itemId: 'i-1', typeId: 'user-type-1' }, + ctx, + ); + + expect( + service.synthesizeUserItemFromCreatorReferences, + ).toHaveBeenCalledWith({ + orgId: 'org-1', + itemId: 'i-1', + knownUserTypeId: 'user-type-1', + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ isSynthetic: true }); + expect(result[0].latest.id).toBe('i-1'); + }); + + it('returns an empty array when neither lookup nor synthesis yields anything', async () => { + const { ctx } = makeContext(); + + const result = await resolveItemsWithId( + { itemId: 'missing', typeId: 'user-type-1' }, + ctx, + ); + + expect(result).toEqual([]); + }); + }); + + describe('type-agnostic path with returnFirstResultOnly', () => { + it('returns the first real submission without invoking synthesis', async () => { + const submission = makeSubmission({ itemId: 'i-1' }); + const { ctx, service } = makeContext({ + getItemByTypeAgnosticIdentifier: jest.fn().mockReturnValue( + singleAsyncIterable({ + latestSubmission: submission, + priorSubmissions: undefined, + }), + ), + }); + + const result = await resolveItemsWithId( + { itemId: 'i-1', returnFirstResultOnly: true }, + ctx, + ); + + expect(result).toHaveLength(1); + expect(result[0].latest.id).toBe('i-1'); + expect( + service.synthesizeUserItemFromCreatorReferences, + ).not.toHaveBeenCalled(); + }); + + it('falls back to synthesis (without knownUserTypeId) when the stream is empty', async () => { + const synthSubmission = makeSubmission({ itemId: 'i-1' }); + const { ctx, service } = makeContext({ + synthesizeUserItemFromCreatorReferences: jest.fn().mockResolvedValue({ + latestSubmission: synthSubmission, + priorSubmissions: undefined, + }), + }); + + const result = await resolveItemsWithId( + { itemId: 'i-1', returnFirstResultOnly: true }, + ctx, + ); + + expect( + service.synthesizeUserItemFromCreatorReferences, + ).toHaveBeenCalledWith({ + orgId: 'org-1', + itemId: 'i-1', + knownUserTypeId: undefined, + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ isSynthetic: true }); + }); + }); + + describe('type-agnostic path without returnFirstResultOnly', () => { + it('falls back to synthesis when the stream is empty', async () => { + const synthSubmission = makeSubmission({ itemId: 'i-1' }); + const { ctx, service } = makeContext({ + synthesizeUserItemFromCreatorReferences: jest.fn().mockResolvedValue({ + latestSubmission: synthSubmission, + priorSubmissions: undefined, + }), + }); + + const result = await resolveItemsWithId({ itemId: 'i-1' }, ctx); + + expect( + service.synthesizeUserItemFromCreatorReferences, + ).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ isSynthetic: true }); + }); + + it('returns real submissions and skips synthesis when the stream yields data', async () => { + const submission = makeSubmission({ itemId: 'i-1' }); + const { ctx, service } = makeContext({ + getItemByTypeAgnosticIdentifier: jest.fn().mockReturnValue( + singleAsyncIterable({ + latestSubmission: submission, + priorSubmissions: undefined, + }), + ), + }); + + const result = await resolveItemsWithId({ itemId: 'i-1' }, ctx); + + expect(result).toHaveLength(1); + expect(result[0].latest.id).toBe('i-1'); + expect( + service.synthesizeUserItemFromCreatorReferences, + ).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/server/graphql/modules/investigation.ts b/server/graphql/modules/investigation.ts index 22b0ff9..b23572e 100644 --- a/server/graphql/modules/investigation.ts +++ b/server/graphql/modules/investigation.ts @@ -17,10 +17,12 @@ import { jsonStringify } from '../../utils/encoding.js'; import { isCoopErrorOfType, makeNotFoundError } from '../../utils/errors.js'; import { MONTH_MS } from '../../utils/time.js'; import { + type GQLQueryItemsWithIdArgs, type GQLQueryResolvers, type GQLRuleEnvironment, type GQLUserHistoryResolvers, } from '../generated.js'; +import { type Context } from '../resolvers.js'; import { formatItemSubmissionForGQL } from '../types.js'; import { unauthenticatedError } from '../utils/errors.js'; import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; @@ -125,6 +127,56 @@ const typeDefs = /* GraphQL */ ` } `; +type FormattedItemSubmission = ReturnType; + +type ItemsWithIdResultEntry = { + latest: FormattedItemSubmission; + prior?: ReadonlyArray; + isSynthetic?: true; +}; + +type ItemInvestigationServiceForItemsWithId = Pick< + Context['services']['ItemInvestigationService'], + | 'getItemByIdentifier' + | 'getItemByTypeAgnosticIdentifier' + | 'synthesizeUserItemFromCreatorReferences' +>; + +export type ItemsWithIdContext = { + getUser: () => { orgId: string } | null | undefined; + services: { + ItemInvestigationService: ItemInvestigationServiceForItemsWithId; + }; +}; + +async function tryBuildSyntheticItemsWithIdResult( + service: Pick< + ItemInvestigationServiceForItemsWithId, + 'synthesizeUserItemFromCreatorReferences' + >, + orgId: string, + itemId: string, + knownUserTypeId?: string, +): Promise | null> { + const synthetic = await service.synthesizeUserItemFromCreatorReferences({ + orgId, + itemId, + knownUserTypeId, + }); + + if (!synthetic) { + return null; + } + + return [ + { + latest: formatItemSubmissionForGQL(synthetic.latestSubmission), + prior: undefined, + isSynthetic: true, + }, + ]; +} + const UserHistory: GQLUserHistoryResolvers = { async executions(it, _, context) { const user = context.getUser(); @@ -177,6 +229,100 @@ const UserHistory: GQLUserHistoryResolvers = { }, }; +export async function resolveItemsWithId( + { itemId, typeId, returnFirstResultOnly }: GQLQueryItemsWithIdArgs, + context: ItemsWithIdContext, +) { + const user = context.getUser(); + if (user == null) { + throw unauthenticatedError('Unauthenticated User'); + } + + if (typeId) { + const item = + await context.services.ItemInvestigationService.getItemByIdentifier({ + orgId: user.orgId, + itemIdentifier: { id: itemId, typeId }, + latestSubmissionOnly: true, + }); + + if (item == null) { + const synthetic = await tryBuildSyntheticItemsWithIdResult( + context.services.ItemInvestigationService, + user.orgId, + itemId, + typeId, + ); + return synthetic ?? []; + } + + return [ + { + latest: formatItemSubmissionForGQL(item.latestSubmission), + prior: item.priorSubmissions?.map((priorSubmission: ItemSubmission) => + formatItemSubmissionForGQL(priorSubmission), + ), + }, + ]; + } + + const itemsAsyncIterator = + context.services.ItemInvestigationService.getItemByTypeAgnosticIdentifier({ + orgId: user.orgId, + itemId, + latestSubmissionOnly: true, + }); + + if (returnFirstResultOnly) { + const items = await asyncIterableToArrayWithTimeoutAndLimit( + itemsAsyncIterator, + 25_000, + 1, + ); + + if (items.length === 0) { + const synthetic = await tryBuildSyntheticItemsWithIdResult( + context.services.ItemInvestigationService, + user.orgId, + itemId, + ); + return synthetic ?? []; + } + + const item = items[0]; + return [ + { + latest: formatItemSubmissionForGQL(item.latestSubmission), + prior: item.priorSubmissions?.map((priorSubmission: ItemSubmission) => + formatItemSubmissionForGQL(priorSubmission), + ), + }, + ]; + } + + // 25s timeout caps slow upstream streams. + const items = await asyncIterableToArrayWithTimeout( + itemsAsyncIterator, + 25_000, + ); + + if (items.length === 0) { + const synthetic = await tryBuildSyntheticItemsWithIdResult( + context.services.ItemInvestigationService, + user.orgId, + itemId, + ); + return synthetic ?? []; + } + + return items.map((it) => ({ + latest: formatItemSubmissionForGQL(it.latestSubmission), + prior: it.priorSubmissions?.map((priorSubmission) => + formatItemSubmissionForGQL(priorSubmission), + ), + })); +} + const Query: GQLQueryResolvers = { async itemSubmissions(_, { itemIdentifiers }, context) { const user = context.getUser(); @@ -255,80 +401,8 @@ const Query: GQLQueryResolvers = { throw e; } }, - async itemsWithId(_, { itemId, typeId, returnFirstResultOnly }, context) { - const user = context.getUser(); - if (user == null) { - throw unauthenticatedError('Unauthenticated User'); - } - - if (typeId) { - const item = - await context.services.ItemInvestigationService.getItemByIdentifier({ - orgId: user.orgId, - itemIdentifier: { id: itemId, typeId }, - latestSubmissionOnly: true, - }); - - if (item == null) { - return []; - } - - return [ - { - latest: formatItemSubmissionForGQL(item.latestSubmission), - prior: item.priorSubmissions?.map((priorSubmission: ItemSubmission) => - formatItemSubmissionForGQL(priorSubmission), - ), - }, - ]; - } - - const itemsAsyncIterator = - context.services.ItemInvestigationService.getItemByTypeAgnosticIdentifier( - { - orgId: user.orgId, - itemId, - latestSubmissionOnly: true, - }, - ); - - if (returnFirstResultOnly) { - const items = await asyncIterableToArrayWithTimeoutAndLimit( - itemsAsyncIterator, - 25_000, - 1, - ); - - if (items.length === 0) { - return []; - } - - const item = items[0]; - return [ - { - latest: formatItemSubmissionForGQL(item.latestSubmission), - prior: item.priorSubmissions?.map((priorSubmission: ItemSubmission) => - formatItemSubmissionForGQL(priorSubmission), - ), - }, - ]; - } else { - const items = await asyncIterableToArrayWithTimeout( - itemsAsyncIterator, - // Set to 25 seconds to avoid long-running requests and we - // want to make sure we get some results in the event of a possible - // timeout (which is why we're using an async iterable in the first - // place instead of just returning an array) - 25_000, - ); - - return items.map((it) => ({ - latest: formatItemSubmissionForGQL(it.latestSubmission), - prior: it.priorSubmissions?.map((priorSubmission) => - formatItemSubmissionForGQL(priorSubmission), - ), - })); - } + async itemsWithId(_, args, context) { + return resolveItemsWithId(args, context); }, async itemWithHistory(_, { itemIdentifier, submissionTime }, context) { const { id: itemId, typeId } = itemIdentifier; diff --git a/server/graphql/modules/manualReviewTool.ts b/server/graphql/modules/manualReviewTool.ts index eb067cd..09da21d 100644 --- a/server/graphql/modules/manualReviewTool.ts +++ b/server/graphql/modules/manualReviewTool.ts @@ -72,6 +72,11 @@ const typeDefs = /* GraphQL */ ` type ItemSubmissions { latest: Item! prior: [Item!] + """ + True when this item was synthesized server-side from indirect references + rather than a real submission. \`latest.data\` is empty when set. + """ + isSynthetic: Boolean } type ItemWithParents { diff --git a/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.test.ts b/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.test.ts new file mode 100644 index 0000000..06ae4bd --- /dev/null +++ b/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.test.ts @@ -0,0 +1,143 @@ +import { ProxyTracerProvider } from '@opentelemetry/api'; + +import type { IDataWarehouse } from '../../../storage/dataWarehouse/IDataWarehouse.js'; +import SafeTracer from '../../../utils/SafeTracer.js'; +import { ClickhouseActionExecutionsAdapter } from './ClickhouseActionExecutionsAdapter.js'; + +function makeWarehouse(rows: ReadonlyArray>) { + const query = jest.fn( + async ( + _q: string, + _t: SafeTracer, + _b?: readonly unknown[], + ): Promise => [...rows], + ); + const warehouse: IDataWarehouse = { + query, + transaction: jest.fn(), + start: jest.fn(), + close: jest.fn(), + getProvider: jest.fn(), + }; + return { warehouse, query }; +} + +function makeAdapter(rows: ReadonlyArray>) { + const { warehouse, query } = makeWarehouse(rows); + const tracer = new SafeTracer(new ProxyTracerProvider().getTracer('noop')); + return { + adapter: new ClickhouseActionExecutionsAdapter(warehouse, tracer), + query, + }; +} + +describe('ClickhouseActionExecutionsAdapter.findInferredUserIdentity', () => { + it('returns null when no rows match', async () => { + const { adapter } = makeAdapter([]); + + const result = await adapter.findInferredUserIdentity({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result).toBeNull(); + }); + + it('projects item_type_id when the row matches via direct user-kind action', async () => { + const ts = '2026-05-01T00:00:00.000Z'; + const { adapter } = makeAdapter([ + { + ts, + item_id: 'i-1', + item_type_id: 'user-type-A', + item_type_kind: 'USER', + item_creator_id: null, + item_creator_type_id: null, + }, + ]); + + const result = await adapter.findInferredUserIdentity({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result).toEqual({ + itemTypeId: 'user-type-A', + lastSeenAt: new Date(ts), + }); + }); + + it('projects item_creator_type_id when the row matches via creator reference', async () => { + const ts = '2026-05-02T00:00:00.000Z'; + const { adapter } = makeAdapter([ + { + ts, + item_id: 'content-99', + item_type_id: 'content-type-X', + item_type_kind: 'CONTENT', + item_creator_id: 'i-1', + item_creator_type_id: 'user-type-B', + }, + ]); + + const result = await adapter.findInferredUserIdentity({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result?.itemTypeId).toBe('user-type-B'); + }); + + it('matches case-insensitively on the projected id', async () => { + const ts = '2026-05-02T00:00:00.000Z'; + const { adapter } = makeAdapter([ + { + ts, + item_id: 'I-1', + item_type_id: 'user-type-A', + item_type_kind: 'USER', + item_creator_id: null, + item_creator_type_id: null, + }, + ]); + + const result = await adapter.findInferredUserIdentity({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result?.itemTypeId).toBe('user-type-A'); + }); + + it('filters out null/empty creator_type_id rows in SQL and uses LIMIT 1', async () => { + const { adapter, query } = makeAdapter([]); + + await adapter.findInferredUserIdentity({ + orgId: 'org-1', + itemId: 'i-1', + }); + + const sentSql = query.mock.calls[0][0]; + expect(sentSql).toContain('item_type_id IS NOT NULL'); + expect(sentSql).toContain("item_type_id != ''"); + expect(sentSql).toContain('item_creator_type_id IS NOT NULL'); + expect(sentSql).toContain("item_creator_type_id != ''"); + expect(sentSql).toContain('LIMIT 1'); + }); + + it('passes the org id and a lookback ds bound to the underlying query', async () => { + const { adapter, query } = makeAdapter([]); + + await adapter.findInferredUserIdentity({ + orgId: 'org-99', + itemId: 'i-1', + lookbackWindowMs: 24 * 60 * 60 * 1000, + }); + + expect(query).toHaveBeenCalledTimes(1); + const sentSql = query.mock.calls[0][0]; + expect(sentSql).toContain('analytics.ACTION_EXECUTIONS'); + expect(sentSql).toContain('org_id'); + expect(sentSql).toContain('org-99'); + }); +}); diff --git a/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.ts b/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.ts index 43dddfc..e844c10 100644 --- a/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.ts +++ b/server/plugins/warehouse/queries/ClickhouseActionExecutionsAdapter.ts @@ -1,19 +1,23 @@ +import type { IDataWarehouse } from '../../../storage/dataWarehouse/IDataWarehouse.js'; +import { jsonParse, type JsonOf } from '../../../utils/encoding.js'; +import type SafeTracer from '../../../utils/SafeTracer.js'; +import { SIX_MONTHS_MS } from '../../../utils/time.js'; +import { formatClickhouseQuery } from '../utils/clickhouseSql.js'; import { type IActionExecutionsAdapter, + type InferredUserIdentityInput, + type InferredUserIdentityRecord, type ItemActionHistoryInput, type ItemActionHistoryRecord, type UserStrikeActionRecord, type UserStrikeActionsInput, } from './IActionExecutionsAdapter.js'; -import type { IDataWarehouse } from '../../../storage/dataWarehouse/IDataWarehouse.js'; -import type SafeTracer from '../../../utils/SafeTracer.js'; -import { formatClickhouseQuery } from '../utils/clickhouseSql.js'; -import { jsonParse, type JsonOf } from '../../../utils/encoding.js'; interface ClickhouseActionExecutionRow { ts: string; item_id: string | null; item_type_id: string | null; + item_type_kind: string; item_creator_id: string | null; item_creator_type_id: string | null; actor_id: string | null; @@ -24,9 +28,7 @@ interface ClickhouseActionExecutionRow { action_source?: string; } -export class ClickhouseActionExecutionsAdapter - implements IActionExecutionsAdapter -{ +export class ClickhouseActionExecutionsAdapter implements IActionExecutionsAdapter { constructor( private readonly warehouse: IDataWarehouse, private readonly tracer: SafeTracer, @@ -119,7 +121,10 @@ export class ClickhouseActionExecutionsAdapter ${limit != null ? `LIMIT ${Number(limit)}` : ''} `; - const rows = (await this.query(sql, params)) as ClickhouseActionExecutionRow[]; + const rows = (await this.query( + sql, + params, + )) as ClickhouseActionExecutionRow[]; return rows .filter((row) => row.item_id && row.item_type_id) @@ -132,6 +137,76 @@ export class ClickhouseActionExecutionsAdapter })); } + async findInferredUserIdentity( + input: InferredUserIdentityInput, + ): Promise { + const { orgId, itemId, lookbackWindowMs = SIX_MONTHS_MS } = input; + + const lookbackStart = new Date(Date.now() - Math.max(1, lookbackWindowMs)); + const lookbackStartDate = lookbackStart.toISOString().slice(0, 10); + + // Filter projected-type-id NULL/empty rows in SQL so a single LIMIT 1 + // suffices — pulling a page and skipping in JS would miss valid older + // rows whenever the most-recent N candidates all happen to be NULL. + const sql = ` + SELECT + ts, + item_id, + item_type_id, + item_type_kind, + item_creator_id, + item_creator_type_id + FROM analytics.ACTION_EXECUTIONS + WHERE org_id = ? + AND ds >= toDate(?) + AND ( + ( + lower(item_id) = lower(?) + AND item_type_kind = 'USER' + AND item_type_id IS NOT NULL + AND item_type_id != '' + ) + OR ( + lower(item_creator_id) = lower(?) + AND item_creator_type_id IS NOT NULL + AND item_creator_type_id != '' + ) + ) + ORDER BY ts DESC + LIMIT 1 + `; + + const rows = await this.query(sql, [ + orgId, + lookbackStartDate, + itemId, + itemId, + ]); + + if (rows.length === 0) { + return null; + } + + const row = rows[0]; + const matchesAsUserAction = + row.item_type_kind === 'USER' && + row.item_id != null && + row.item_id.toLowerCase() === itemId.toLowerCase(); + + const userTypeId = matchesAsUserAction + ? row.item_type_id + : row.item_creator_type_id; + + if (!userTypeId) { + return null; + } + + return { + itemTypeId: userTypeId, + lastSeenAt: new Date(row.ts), + }; + } + private parseJsonArray( jsonString: string | null | undefined, ): Array<{ id: string }> | null { @@ -171,11 +246,7 @@ export class ClickhouseActionExecutionsAdapter params: readonly unknown[], ): Promise { const formatted = formatClickhouseQuery(statement, params); - const response = await this.warehouse.query( - formatted, - this.tracer, - ); + const response = await this.warehouse.query(formatted, this.tracer); return response as readonly T[]; } } - diff --git a/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.test.ts b/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.test.ts new file mode 100644 index 0000000..f19446e --- /dev/null +++ b/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.test.ts @@ -0,0 +1,88 @@ +import { ProxyTracerProvider } from '@opentelemetry/api'; + +import type { IDataWarehouse } from '../../../storage/dataWarehouse/IDataWarehouse.js'; +import SafeTracer from '../../../utils/SafeTracer.js'; +import { ClickhouseContentApiRequestsAdapter } from './ClickhouseContentApiRequestsAdapter.js'; + +function makeWarehouse(rows: ReadonlyArray>) { + const query = jest.fn( + async ( + _q: string, + _t: SafeTracer, + _b?: readonly unknown[], + ): Promise => [...rows], + ); + const warehouse: IDataWarehouse = { + query, + transaction: jest.fn(), + start: jest.fn(), + close: jest.fn(), + getProvider: jest.fn(), + }; + return { warehouse, query }; +} + +function makeAdapter(rows: ReadonlyArray>) { + const { warehouse, query } = makeWarehouse(rows); + const tracer = new SafeTracer(new ProxyTracerProvider().getTracer('noop')); + return { + adapter: new ClickhouseContentApiRequestsAdapter(warehouse, tracer), + query, + }; +} + +describe('ClickhouseContentApiRequestsAdapter.findInferredUserIdentityFromCreators', () => { + it('returns null when no rows match', async () => { + const { adapter } = makeAdapter([]); + + const result = await adapter.findInferredUserIdentityFromCreators({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result).toBeNull(); + }); + + it('returns the most-recent creator type id when a row matches', async () => { + const ts = '2026-05-01T12:00:00.000Z'; + const { adapter } = makeAdapter([ + { ts, item_creator_type_id: 'user-type-A' }, + ]); + + const result = await adapter.findInferredUserIdentityFromCreators({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result).toEqual({ + itemTypeId: 'user-type-A', + lastSeenAt: new Date(ts), + }); + }); + + it('returns null when the row has a null creator type id', async () => { + const { adapter } = makeAdapter([ + { ts: '2026-05-01T00:00:00.000Z', item_creator_type_id: null }, + ]); + + const result = await adapter.findInferredUserIdentityFromCreators({ + orgId: 'org-1', + itemId: 'i-1', + }); + + expect(result).toBeNull(); + }); + + it('targets analytics.CONTENT_API_REQUESTS and filters to successful events', async () => { + const { adapter, query } = makeAdapter([]); + + await adapter.findInferredUserIdentityFromCreators({ + orgId: 'org-1', + itemId: 'i-1', + }); + + const sentSql = query.mock.calls[0][0]; + expect(sentSql).toContain('analytics.CONTENT_API_REQUESTS'); + expect(sentSql).toContain(`event = 'REQUEST_SUCCEEDED'`); + }); +}); diff --git a/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.ts b/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.ts index 7507641..b20983a 100644 --- a/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.ts +++ b/server/plugins/warehouse/queries/ClickhouseContentApiRequestsAdapter.ts @@ -1,15 +1,18 @@ import type { ItemIdentifier } from '@roostorg/types'; +import type { IDataWarehouse } from '../../../storage/dataWarehouse/IDataWarehouse.js'; +import type SafeTracer from '../../../utils/SafeTracer.js'; +import { SIX_MONTHS_MS } from '../../../utils/time.js'; +import { formatClickhouseQuery } from '../utils/clickhouseSql.js'; import { - type ContentApiRequestRecord, - type ContentApiRequestCountRecord, type ContentApiImageCountRecord, + type ContentApiRequestCountRecord, type ContentApiRequestQueryOptions, + type ContentApiRequestRecord, type IContentApiRequestsAdapter, + type InferredUserIdentityFromCreatorsInput, + type InferredUserIdentityFromCreatorsRecord, } from './IContentApiRequestsAdapter.js'; -import type { IDataWarehouse } from '../../../storage/dataWarehouse/IDataWarehouse.js'; -import type SafeTracer from '../../../utils/SafeTracer.js'; -import { formatClickhouseQuery } from '../utils/clickhouseSql.js'; interface ClickhouseContentApiRow { item_data: unknown; @@ -26,9 +29,12 @@ interface CountRow { count: number; } -export class ClickhouseContentApiRequestsAdapter - implements IContentApiRequestsAdapter -{ +interface ClickhouseInferredUserRow { + ts: string; + item_creator_type_id: string | null; +} + +export class ClickhouseContentApiRequestsAdapter implements IContentApiRequestsAdapter { constructor( private readonly warehouse: IDataWarehouse, private readonly tracer: SafeTracer, @@ -39,12 +45,10 @@ export class ClickhouseContentApiRequestsAdapter item: ItemIdentifier, options?: ContentApiRequestQueryOptions, ): Promise> { - const { latestOnly = false, lookbackWindowMs = 6 * 30 * 24 * 60 * 60 * 1000 } = + const { latestOnly = false, lookbackWindowMs = SIX_MONTHS_MS } = options ?? {}; - const lookbackStart = new Date( - Date.now() - Math.max(1, lookbackWindowMs), - ); + const lookbackStart = new Date(Date.now() - Math.max(1, lookbackWindowMs)); const lookbackStartDate = lookbackStart.toISOString().slice(0, 10); const conditions = [ @@ -55,12 +59,7 @@ export class ClickhouseContentApiRequestsAdapter 'ds >= toDate(?)', ]; - const params: unknown[] = [ - orgId, - item.id, - item.typeId, - lookbackStartDate, - ]; + const params: unknown[] = [orgId, item.id, item.typeId, lookbackStartDate]; const sql = ` SELECT @@ -154,16 +153,56 @@ export class ClickhouseContentApiRequestsAdapter })); } + async findInferredUserIdentityFromCreators( + input: InferredUserIdentityFromCreatorsInput, + ): Promise { + const { orgId, itemId, lookbackWindowMs = SIX_MONTHS_MS } = input; + + const lookbackStart = new Date(Date.now() - Math.max(1, lookbackWindowMs)); + const lookbackStartDate = lookbackStart.toISOString().slice(0, 10); + + const sql = ` + SELECT + ts, + item_creator_type_id + FROM analytics.CONTENT_API_REQUESTS + WHERE org_id = ? + AND event = 'REQUEST_SUCCEEDED' + AND ds >= toDate(?) + AND lower(item_creator_id) = lower(?) + AND item_creator_type_id IS NOT NULL + AND item_creator_type_id != '' + ORDER BY ts DESC + LIMIT 1 + `; + + const rows = await this.query(sql, [ + orgId, + lookbackStartDate, + itemId, + ]); + + if (rows.length === 0) { + return null; + } + + const row = rows[0]; + if (!row.item_creator_type_id) { + return null; + } + + return { + itemTypeId: row.item_creator_type_id, + lastSeenAt: new Date(row.ts), + }; + } + private async query( statement: string, params: readonly unknown[], ): Promise { const formatted = formatClickhouseQuery(statement, params); - const result = await this.warehouse.query( - formatted, - this.tracer, - ); + const result = await this.warehouse.query(formatted, this.tracer); return result as readonly T[]; } } - diff --git a/server/plugins/warehouse/queries/IActionExecutionsAdapter.ts b/server/plugins/warehouse/queries/IActionExecutionsAdapter.ts index 644b033..76b7c49 100644 --- a/server/plugins/warehouse/queries/IActionExecutionsAdapter.ts +++ b/server/plugins/warehouse/queries/IActionExecutionsAdapter.ts @@ -35,6 +35,17 @@ export interface UserStrikeActionsInput { limit?: number; } +export interface InferredUserIdentityInput { + orgId: string; + itemId: string; + lookbackWindowMs?: number; +} + +export interface InferredUserIdentityRecord { + itemTypeId: string; + lastSeenAt: Date; +} + export interface IActionExecutionsAdapter { getItemActionHistory( input: ItemActionHistoryInput, @@ -43,5 +54,9 @@ export interface IActionExecutionsAdapter { getRecentUserStrikeActions( input: UserStrikeActionsInput, ): Promise>; -} + /** Infer the user `itemTypeId` for an id with no submission record. */ + findInferredUserIdentity( + input: InferredUserIdentityInput, + ): Promise; +} diff --git a/server/plugins/warehouse/queries/IContentApiRequestsAdapter.ts b/server/plugins/warehouse/queries/IContentApiRequestsAdapter.ts index cc60791..9c1b913 100644 --- a/server/plugins/warehouse/queries/IContentApiRequestsAdapter.ts +++ b/server/plugins/warehouse/queries/IContentApiRequestsAdapter.ts @@ -25,6 +25,17 @@ export interface ContentApiImageCountRecord { count: number; } +export interface InferredUserIdentityFromCreatorsInput { + orgId: string; + itemId: string; + lookbackWindowMs?: number; +} + +export interface InferredUserIdentityFromCreatorsRecord { + itemTypeId: string; + lastSeenAt: Date; +} + export interface IContentApiRequestsAdapter { getSuccessfulRequestsForItem( orgId: string, @@ -43,5 +54,12 @@ export interface IContentApiRequestsAdapter { start: Date, end: Date, ): Promise>; -} + /** + * Infer the user `itemTypeId` from rows where `item_creator_id = itemId`. + * Returns the most-recent `item_creator_type_id` or `null`. + */ + findInferredUserIdentityFromCreators( + input: InferredUserIdentityFromCreatorsInput, + ): Promise; +} diff --git a/server/services/itemInvestigationService/itemInvestigationServiceAdapter.ts b/server/services/itemInvestigationService/itemInvestigationServiceAdapter.ts index 9b50ca6..aeb20a1 100644 --- a/server/services/itemInvestigationService/itemInvestigationServiceAdapter.ts +++ b/server/services/itemInvestigationService/itemInvestigationServiceAdapter.ts @@ -1,9 +1,10 @@ import { ReadableStream } from 'node:stream/web'; import type { ItemIdentifier } from '@roostorg/types'; + import { type Dependencies } from '../../iocContainer/index.js'; +import { type IActionExecutionsAdapter } from '../../plugins/warehouse/queries/IActionExecutionsAdapter.js'; +import { type IContentApiRequestsAdapter } from '../../plugins/warehouse/queries/IContentApiRequestsAdapter.js'; import { type Scylla } from '../../scylla/index.js'; -import type { ContentApiRequestLogEntry } from '../analyticsLoggers/ContentApiLogger.js'; -import { type RuleExecutionCorrelationId } from '../analyticsLoggers/ruleExecutionLoggingUtils.js'; import { type CorrelationId } from '../../utils/correlationIds.js'; import { mapAsyncIterable } from '../../utils/iterables.js'; import { __throw } from '../../utils/misc.js'; @@ -11,6 +12,8 @@ import { type PublicMethodNames, type ReplaceDeep, } from '../../utils/typescript-types.js'; +import type { ContentApiRequestLogEntry } from '../analyticsLoggers/ContentApiLogger.js'; +import { type RuleExecutionCorrelationId } from '../analyticsLoggers/ruleExecutionLoggingUtils.js'; import { itemSubmissionWithTypeIdentifierToItemSubmission, type ItemSubmissionWithTypeIdentifier, @@ -22,12 +25,7 @@ import { ItemInvestigationService, type SubmissionsForItemWithTypeIdentifier, } from './itemInvestigationService.js'; -import { - type IActionExecutionsAdapter, -} from '../../plugins/warehouse/queries/IActionExecutionsAdapter.js'; -import { - type IContentApiRequestsAdapter, -} from '../../plugins/warehouse/queries/IContentApiRequestsAdapter.js'; +import { synthesizeUserItemFromCreatorReferences } from './synthesizeUserItemFromCreatorReferences.js'; type AdaptedReturnType> = ReplaceDeep< @@ -57,8 +55,8 @@ export class ItemInvestigationServiceAdapter { scylla: Scylla, tracer: Dependencies['Tracer'], partialItemsService: Dependencies['PartialItemsService'], - actionExecutionsAdapter: IActionExecutionsAdapter, - contentApiRequestsAdapter: IContentApiRequestsAdapter, + private readonly actionExecutionsAdapter: IActionExecutionsAdapter, + private readonly contentApiRequestsAdapter: IContentApiRequestsAdapter, private readonly moderationConfigService: Dependencies['ModerationConfigService'], meter: Dependencies['Meter'], ) { @@ -281,6 +279,40 @@ export class ItemInvestigationServiceAdapter { return this.service.getItemActionHistory(opts); } + /** See `synthesizeUserItemFromCreatorReferences.ts`. */ + async synthesizeUserItemFromCreatorReferences(opts: { + orgId: string; + itemId: string; + knownUserTypeId?: string; + }): Promise { + return synthesizeUserItemFromCreatorReferences({ + ...opts, + scyllaCreatorRefExists: async (input) => + this.#hasAnySubmissionsCreatedBy(input.orgId, input.creatorIdentifier), + actionExecutionsAdapter: this.actionExecutionsAdapter, + contentApiRequestsAdapter: this.contentApiRequestsAdapter, + moderationConfigService: this.moderationConfigService, + }); + } + + async #hasAnySubmissionsCreatedBy( + orgId: string, + creatorIdentifier: ItemIdentifier, + ): Promise { + const stream = this.service.getItemSubmissionsByCreator({ + orgId, + itemCreatorIdentifier: creatorIdentifier, + limit: 1, + latestSubmissionsOnly: true, + }); + + // Early-return calls `.return()` on the iterator so the Scylla cursor closes. + for await (const _unused of stream) { + return true; + } + return false; + } + #adaptInternalStreamToItemSubmissionsForItem( orgId: string, submissionsForItemWithTypeIdentifierStream: AsyncIterable, diff --git a/server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.test.ts b/server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.test.ts new file mode 100644 index 0000000..9f14a7f --- /dev/null +++ b/server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.test.ts @@ -0,0 +1,310 @@ +import { type UserItemType } from '../moderationConfigService/index.js'; +import { synthesizeUserItemFromCreatorReferences } from './synthesizeUserItemFromCreatorReferences.js'; + +function makeUserType(overrides: Partial = {}): UserItemType { + return { + id: 'user-type-1', + kind: 'USER', + name: 'User', + description: null, + version: '2025-01-01', + schema: [ + { name: 'username', type: 'STRING', required: false, container: null }, + ], + schemaVariant: 'original', + schemaFieldRoles: {}, + orgId: 'org-1', + isDefaultUserType: false, + ...overrides, + }; +} + +type Deps = Parameters[0]; + +function makeDeps(overrides: Partial = {}): Deps { + return { + orgId: 'org-1', + itemId: 'user-id-xyz', + scyllaCreatorRefExists: jest.fn().mockResolvedValue(false), + actionExecutionsAdapter: { + findInferredUserIdentity: jest.fn().mockResolvedValue(null), + }, + contentApiRequestsAdapter: { + findInferredUserIdentityFromCreators: jest.fn().mockResolvedValue(null), + }, + moderationConfigService: { + getItemType: jest.fn(), + getItemTypes: jest.fn().mockResolvedValue([]), + }, + ...overrides, + }; +} + +describe('synthesizeUserItemFromCreatorReferences', () => { + it('returns null when every source comes up empty', async () => { + const result = await synthesizeUserItemFromCreatorReferences(makeDeps()); + expect(result).toBeNull(); + }); + + describe('with a pinned knownUserTypeId (URL-driven typed path)', () => { + it('synthesizes when Scylla shows the id is referenced as a creator', async () => { + const userType = makeUserType(); + const scyllaCreatorRefExists = jest.fn().mockResolvedValue(true); + const findInferredUserIdentity = jest.fn(); + + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + knownUserTypeId: userType.id, + scyllaCreatorRefExists, + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(userType), + getItemTypes: jest.fn(), + }, + actionExecutionsAdapter: { findInferredUserIdentity }, + }), + ); + + expect(result?.latestSubmission.itemType).toEqual(userType); + expect(result?.latestSubmission.itemId).toBe('user-id-xyz'); + expect(result?.latestSubmission.submissionTime).toBeUndefined(); + expect(result?.latestSubmission.data).toEqual({}); + // Fast path: no warehouse adapters consulted when Scylla confirms. + expect(findInferredUserIdentity).not.toHaveBeenCalled(); + expect(scyllaCreatorRefExists).toHaveBeenCalledWith({ + orgId: 'org-1', + creatorIdentifier: { id: 'user-id-xyz', typeId: userType.id }, + }); + }); + + it('does NOT synthesize when the pinned type is not a USER type', async () => { + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + knownUserTypeId: 'content-type-1', + scyllaCreatorRefExists: jest.fn().mockResolvedValue(true), + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue({ + id: 'content-type-1', + kind: 'CONTENT', + }), + getItemTypes: jest.fn().mockResolvedValue([]), + }, + }), + ); + + // Even though Scylla had a hit, content/thread synthesis is out of + // scope for this fallback. We fall through and find nothing. + expect(result).toBeNull(); + }); + + it('falls through to inference when the pinned type has no Scylla references', async () => { + const userType = makeUserType(); + // Pinned type returns null from Scylla; we should keep looking via the + // inference path. Action-executions confirms. + const scyllaCreatorRefExists = jest.fn().mockResolvedValue(false); + + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + knownUserTypeId: userType.id, + scyllaCreatorRefExists, + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(userType), + getItemTypes: jest.fn().mockResolvedValue([userType]), + }, + actionExecutionsAdapter: { + findInferredUserIdentity: jest.fn().mockResolvedValue({ + itemTypeId: userType.id, + lastSeenAt: new Date(), + }), + }, + }), + ); + + expect(result?.latestSubmission.itemType).toEqual(userType); + }); + }); + + it('mints a deterministic synthetic submission id derived from the itemId', async () => { + const userType = makeUserType(); + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + itemId: 'user-id-xyz', + scyllaCreatorRefExists: jest.fn().mockResolvedValue(true), + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(userType), + getItemTypes: jest.fn().mockResolvedValue([userType]), + }, + }), + ); + + expect(result?.latestSubmission.submissionId).toBe('synthetic:user-id-xyz'); + }); + + it('short-circuits when the org has no USER item types (no Scylla calls)', async () => { + const scyllaCreatorRefExists = jest.fn(); + const findInferredUserIdentity = jest.fn(); + + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + scyllaCreatorRefExists, + moderationConfigService: { + getItemType: jest.fn(), + getItemTypes: jest.fn().mockResolvedValue([ + // org has only non-USER types + { id: 'content-1', kind: 'CONTENT' }, + { id: 'thread-1', kind: 'THREAD' }, + ]), + }, + actionExecutionsAdapter: { findInferredUserIdentity }, + }), + ); + + expect(scyllaCreatorRefExists).not.toHaveBeenCalled(); + // We still consult the warehouse since the type-agnostic lookup may + // surface a real id even when the org has no user types we can sweep. + expect(findInferredUserIdentity).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('does NOT re-check the already-checked pinned type in the broad sweep', async () => { + const pinned = makeUserType({ id: 'utype-pinned' }); + const other = makeUserType({ id: 'utype-other' }); + const scyllaCreatorRefExists = jest.fn().mockResolvedValue(false); + + await synthesizeUserItemFromCreatorReferences( + makeDeps({ + knownUserTypeId: pinned.id, + scyllaCreatorRefExists, + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(pinned), + getItemTypes: jest.fn().mockResolvedValue([pinned, other]), + }, + }), + ); + + const sweptTypeIds = scyllaCreatorRefExists.mock.calls.map( + ([{ creatorIdentifier }]) => creatorIdentifier.typeId, + ); + // Fast path probes the pinned type once; broad sweep must skip it. + expect(sweptTypeIds).toEqual([pinned.id, other.id]); + }); + + describe('inference path (no pinned typeId)', () => { + it('synthesizes from a Scylla creator-by-id match on the first user type tried', async () => { + const userType = makeUserType({ id: 'utype-A' }); + const scyllaCreatorRefExists = jest + .fn() + .mockImplementation( + async ({ creatorIdentifier }) => + creatorIdentifier.typeId === userType.id, + ); + const findInferredUserIdentity = jest.fn(); + + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + scyllaCreatorRefExists, + moderationConfigService: { + getItemType: jest.fn(), + getItemTypes: jest.fn().mockResolvedValue([userType]), + }, + actionExecutionsAdapter: { findInferredUserIdentity }, + }), + ); + + expect(result?.latestSubmission.itemType).toEqual(userType); + // Warehouse adapters never consulted when Scylla finds a match. + expect(findInferredUserIdentity).not.toHaveBeenCalled(); + }); + + it('falls back to action executions when Scylla creator refs are empty', async () => { + const userType = makeUserType({ id: 'utype-from-actions' }); + const findInferredUserIdentity = jest.fn().mockResolvedValue({ + itemTypeId: userType.id, + lastSeenAt: new Date(), + }); + const fromCreators = jest.fn(); + + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(userType), + getItemTypes: jest.fn().mockResolvedValue([userType]), + }, + actionExecutionsAdapter: { findInferredUserIdentity }, + contentApiRequestsAdapter: { + findInferredUserIdentityFromCreators: fromCreators, + }, + }), + ); + + expect(result?.latestSubmission.itemType).toEqual(userType); + expect(fromCreators).not.toHaveBeenCalled(); + }); + + it('only consults content-API creators when action executions return null', async () => { + const userType = makeUserType({ id: 'utype-from-content-api' }); + const findInferredUserIdentity = jest.fn().mockResolvedValue(null); + const findInferredUserIdentityFromCreators = jest.fn().mockResolvedValue({ + itemTypeId: userType.id, + lastSeenAt: new Date(), + }); + + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(userType), + getItemTypes: jest.fn().mockResolvedValue([userType]), + }, + actionExecutionsAdapter: { findInferredUserIdentity }, + contentApiRequestsAdapter: { findInferredUserIdentityFromCreators }, + }), + ); + + expect(findInferredUserIdentity).toHaveBeenCalledTimes(1); + expect(findInferredUserIdentityFromCreators).toHaveBeenCalledTimes(1); + expect(result?.latestSubmission.itemType).toEqual(userType); + }); + + it('refuses to synthesize when the inferred typeId no longer resolves to any type', async () => { + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + actionExecutionsAdapter: { + findInferredUserIdentity: jest.fn().mockResolvedValue({ + itemTypeId: 'deleted-type', + lastSeenAt: new Date(), + }), + }, + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue(undefined), + getItemTypes: jest.fn().mockResolvedValue([]), + }, + }), + ); + + expect(result).toBeNull(); + }); + + it('refuses to synthesize when the inferred type is not a USER type', async () => { + // Guards against accidentally surfacing a content/thread id with a + // missing submission as if it were a user. + const result = await synthesizeUserItemFromCreatorReferences( + makeDeps({ + actionExecutionsAdapter: { + findInferredUserIdentity: jest.fn().mockResolvedValue({ + itemTypeId: 'content-type-1', + lastSeenAt: new Date(), + }), + }, + moderationConfigService: { + getItemType: jest.fn().mockResolvedValue({ + id: 'content-type-1', + kind: 'CONTENT', + }), + getItemTypes: jest.fn().mockResolvedValue([]), + }, + }), + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.ts b/server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.ts new file mode 100644 index 0000000..90b203c --- /dev/null +++ b/server/services/itemInvestigationService/synthesizeUserItemFromCreatorReferences.ts @@ -0,0 +1,155 @@ +import { type ItemIdentifier } from '@roostorg/types'; + +import { type Dependencies } from '../../iocContainer/index.js'; +import { type IActionExecutionsAdapter } from '../../plugins/warehouse/queries/IActionExecutionsAdapter.js'; +import { type IContentApiRequestsAdapter } from '../../plugins/warehouse/queries/IContentApiRequestsAdapter.js'; +import { instantiateOpaqueType } from '../../utils/typescript-types.js'; +import { + type ItemSubmission, + type NormalizedItemData, + type SubmissionId, +} from '../itemProcessingService/index.js'; +import { type UserItemType } from '../moderationConfigService/index.js'; + +export type SyntheticUserItemSubmissionsForItem = { + latestSubmission: ItemSubmission; + priorSubmissions: undefined; +}; + +const EMPTY_NORMALIZED_DATA: NormalizedItemData = + instantiateOpaqueType({}); + +/** Sentinel id so callers/logs can recognize synthetic submissions. */ +export function makeSyntheticSubmissionId(itemId: string): SubmissionId { + return instantiateOpaqueType(`synthetic:${itemId}`); +} + +export function makeSyntheticUserSubmission( + itemId: string, + itemType: UserItemType, +): ItemSubmission { + return instantiateOpaqueType({ + submissionId: makeSyntheticSubmissionId(itemId), + submissionTime: undefined, + itemId, + creator: undefined, + data: EMPTY_NORMALIZED_DATA, + itemType, + }); +} + +/** + * Resolve a `UserItem` for an id we never received by following indirect + * references. Tries Scylla `item_submission_by_creator`, then ClickHouse + * `ACTION_EXECUTIONS`, then `CONTENT_API_REQUESTS`. Without `knownUserTypeId` + * the Scylla sweep issues one call per USER type in the org (parallel). + */ +export async function synthesizeUserItemFromCreatorReferences(opts: { + orgId: string; + itemId: string; + knownUserTypeId?: string; + scyllaCreatorRefExists: (input: { + orgId: string; + creatorIdentifier: ItemIdentifier; + }) => Promise; + actionExecutionsAdapter: Pick< + IActionExecutionsAdapter, + 'findInferredUserIdentity' + >; + contentApiRequestsAdapter: Pick< + IContentApiRequestsAdapter, + 'findInferredUserIdentityFromCreators' + >; + moderationConfigService: Pick< + Dependencies['ModerationConfigService'], + 'getItemType' | 'getItemTypes' + >; +}): Promise { + const { + orgId, + itemId, + knownUserTypeId, + scyllaCreatorRefExists, + actionExecutionsAdapter, + contentApiRequestsAdapter, + moderationConfigService, + } = opts; + + // Fast path: pinned type + Scylla creator ref. Misses fall through to + // inference, so this is a fast-path filter, not a typo-rejection guard. + let pinnedTypeAlreadyChecked: string | null = null; + if (knownUserTypeId !== undefined) { + const pinnedType = await moderationConfigService.getItemType({ + orgId, + itemTypeSelector: { id: knownUserTypeId }, + }); + if (pinnedType && pinnedType.kind === 'USER') { + pinnedTypeAlreadyChecked = pinnedType.id; + const referenced = await scyllaCreatorRefExists({ + orgId, + creatorIdentifier: { id: itemId, typeId: knownUserTypeId }, + }); + if (referenced) { + return { + latestSubmission: makeSyntheticUserSubmission(itemId, pinnedType), + priorSubmissions: undefined, + }; + } + } + } + + const allTypes = await moderationConfigService.getItemTypes({ orgId }); + const userTypesToSweep = allTypes.filter( + (t): t is UserItemType => + t.kind === 'USER' && t.id !== pinnedTypeAlreadyChecked, + ); + + if (userTypesToSweep.length > 0) { + const lookups = await Promise.all( + userTypesToSweep.map(async (userType) => { + const referenced = await scyllaCreatorRefExists({ + orgId, + creatorIdentifier: { id: itemId, typeId: userType.id }, + }); + return referenced ? userType : null; + }), + ); + const matchedType = lookups.find((t): t is UserItemType => t !== null); + if (matchedType) { + return { + latestSubmission: makeSyntheticUserSubmission(itemId, matchedType), + priorSubmissions: undefined, + }; + } + } + + const fromActions = await actionExecutionsAdapter.findInferredUserIdentity({ + orgId, + itemId, + }); + + const inferred = + fromActions ?? + (await contentApiRequestsAdapter.findInferredUserIdentityFromCreators({ + orgId, + itemId, + })); + + if (!inferred) { + return null; + } + + const itemType = await moderationConfigService.getItemType({ + orgId, + itemTypeSelector: { id: inferred.itemTypeId }, + }); + + if (!itemType || itemType.kind !== 'USER') { + return null; + } + + return { + latestSubmission: makeSyntheticUserSubmission(itemId, itemType), + priorSubmissions: undefined, + }; +} diff --git a/server/utils/time.ts b/server/utils/time.ts index 0da7d3c..0e36132 100644 --- a/server/utils/time.ts +++ b/server/utils/time.ts @@ -8,6 +8,7 @@ export const HOUR_MS = MINUTE_MS * 60; export const DAY_MS = HOUR_MS * 24; export const WEEK_MS = DAY_MS * 7; export const MONTH_MS = DAY_MS * 30; +export const SIX_MONTHS_MS = MONTH_MS * 6; export const YEAR_MS = DAY_MS * 365; // NB: we call this DateOnlyString to avoid a conflict with the DateString type -- 2.51.2