From ab1ed56b05cbacbebcbb6c147e1f5a7dade43ea9 Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Tue, 23 Jun 2026 09:46:16 -0700 Subject: [PATCH] [791] Extend parameterized actions to proactive rules and user strikes (#792) * [791] Extend parameterized actions to proactive rules and user strikes * code review fixes * Move configuredParameters onto Action type, deprecate rule-level actionParameters - Add `configuredParameters: JSONObject` to ActionBase and all implementing types so consumers access configured values directly from each action. - Deprecate the top-level `actionParameters` field on Rule types. - Update client query to read `configuredParameters` from action objects. - Remove stale comments from validation tests. * code review fixes --- client/src/graphql/generated.ts | 245 ++++++++++++++++++ .../dashboard/actions/ActionsDashboard.tsx | 17 ++ .../rule_form/RuleActionParametersEditor.tsx | 118 +++++++++ .../dashboard/rules/rule_form/RuleForm.tsx | 113 ++++++++ .../userStrikes/ThresholdsAndSettingsTab.tsx | 132 +++++++++- ...action-parameters-to-rules-and-strikes.sql | 35 +++ server/graphql/datasources/RuleApi.ts | 69 ++++- .../datasources/buildGraphqlRuleParent.ts | 25 +- .../datasources/ruleKyselyPersistence.ts | 28 +- server/graphql/generated.ts | 93 +++++++ server/graphql/modules/action.ts | 33 +++ server/graphql/modules/org.ts | 73 +++++- server/graphql/modules/rule.ts | 83 +++++- server/rule_engine/RuleEngine.test.ts | 66 +++++ server/rule_engine/RuleEngine.ts | 74 +++++- .../moderationConfigService/dbTypes.ts | 4 + .../services/moderationConfigService/index.ts | 5 +- .../moderationConfigService.test.ts | 2 +- .../moderationConfigService.ts | 11 +- .../modules/ActionOperations.ts | 19 +- .../modules/UserStrikeOperations.ts | 9 + .../actionParameterValueValidation.test.ts | 80 ++++++ .../modules/actionParameterValueValidation.ts | 41 ++- .../userStrikeService/userStrikeService.ts | 19 +- 24 files changed, 1350 insertions(+), 44 deletions(-) create mode 100644 client/src/webpages/dashboard/rules/rule_form/RuleActionParametersEditor.tsx create mode 100644 db/src/scripts/api-server-pg/2026.06.14T02.33.06.add-action-parameters-to-rules-and-strikes.sql create mode 100644 server/rule_engine/RuleEngine.test.ts create mode 100644 server/services/moderationConfigService/modules/actionParameterValueValidation.test.ts diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index e1062fb..a459236 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -71,6 +71,11 @@ export type GQLAction = export type GQLActionBase = { readonly applyUserStrikes?: Maybe; + /** + * Configured parameter values for this action in the context of a rule or + * strike threshold. Null when resolved outside of a rule context. + */ + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -592,6 +597,8 @@ export type GQLContentManualReviewJobPayload = { export type GQLContentRule = GQLRule & { readonly __typename: 'ContentRule'; + /** @deprecated Use configuredParameters on each Action instead. */ + readonly actionParameters: ReadonlyArray; readonly actions: ReadonlyArray; readonly backtests: ReadonlyArray; readonly conditionSet: GQLConditionSet; @@ -740,6 +747,9 @@ export type GQLCreateContentItemTypeInput = { export type GQLCreateContentRuleInput = { readonly actionIds: ReadonlyArray; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly conditionSet: GQLConditionSetInput; readonly contentTypeIds: ReadonlyArray; readonly description?: InputMaybe; @@ -847,6 +857,9 @@ export type GQLCreateUserItemTypeInput = { export type GQLCreateUserRuleInput = { readonly actionIds: ReadonlyArray; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly conditionSet: GQLConditionSetInput; readonly description?: InputMaybe; readonly expirationTime?: InputMaybe; @@ -868,6 +881,7 @@ export type GQLCustomAction = GQLActionBase & { readonly callbackUrl: Scalars['String']['output']; readonly callbackUrlBody?: Maybe; readonly callbackUrlHeaders?: Maybe; + readonly configuredParameters?: Maybe; /** * Deprecated alias for `parameters` retained for back-compat with the * initial MRT-only parameter implementation. New consumers should read @@ -1104,6 +1118,7 @@ export type GQLDisabledInfoInput = { export type GQLEnqueueAuthorToMrtAction = GQLActionBase & { readonly __typename: 'EnqueueAuthorToMrtAction'; readonly applyUserStrikes: Scalars['Boolean']['output']; + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -1116,6 +1131,7 @@ export type GQLEnqueueAuthorToMrtAction = GQLActionBase & { export type GQLEnqueueToMrtAction = GQLActionBase & { readonly __typename: 'EnqueueToMrtAction'; readonly applyUserStrikes?: Maybe; + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -1128,6 +1144,7 @@ export type GQLEnqueueToMrtAction = GQLActionBase & { export type GQLEnqueueToNcmecAction = GQLActionBase & { readonly __typename: 'EnqueueToNcmecAction'; readonly applyUserStrikes?: Maybe; + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -4092,6 +4109,8 @@ export const GQLRoutingRuleStatus = { export type GQLRoutingRuleStatus = (typeof GQLRoutingRuleStatus)[keyof typeof GQLRoutingRuleStatus]; export type GQLRule = { + /** @deprecated Use configuredParameters on each Action instead. */ + readonly actionParameters: ReadonlyArray; readonly actions: ReadonlyArray; readonly backtests: ReadonlyArray; readonly conditionSet: GQLConditionSet; @@ -4114,6 +4133,17 @@ export type GQLRuleBacktestsArgs = { ids?: InputMaybe>; }; +export type GQLRuleActionParameterValues = { + readonly __typename: 'RuleActionParameterValues'; + readonly actionId: Scalars['ID']['output']; + readonly parameters: Scalars['JSONObject']['output']; +}; + +export type GQLRuleActionParameterValuesInput = { + readonly actionId: Scalars['ID']['input']; + readonly parameters: Scalars['JSONObject']['input']; +}; + export const GQLRuleEnvironment = { Background: 'BACKGROUND', Backtest: 'BACKTEST', @@ -4296,6 +4326,7 @@ export type GQLSetPluginIntegrationConfigInput = { }; export type GQLSetUserStrikeThresholdInput = { + readonly actionParameters?: InputMaybe; readonly actions: ReadonlyArray; readonly threshold: Scalars['Int']['input']; }; @@ -4738,6 +4769,9 @@ export type GQLUpdateContentItemTypeInput = { export type GQLUpdateContentRuleInput = { readonly actionIds?: InputMaybe>; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly cancelRunningBacktests?: InputMaybe; readonly conditionSet?: InputMaybe; readonly contentTypeIds?: InputMaybe>; @@ -4911,6 +4945,9 @@ export type GQLUpdateUserItemTypeInput = { export type GQLUpdateUserRuleInput = { readonly actionIds?: InputMaybe>; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly cancelRunningBacktests?: InputMaybe; readonly conditionSet?: InputMaybe; readonly description?: InputMaybe; @@ -5112,6 +5149,8 @@ export const GQLUserRole = { export type GQLUserRole = (typeof GQLUserRole)[keyof typeof GQLUserRole]; export type GQLUserRule = GQLRule & { readonly __typename: 'UserRule'; + /** @deprecated Use configuredParameters on each Action instead. */ + readonly actionParameters: ReadonlyArray; readonly actions: ReadonlyArray; readonly backtests: ReadonlyArray; readonly conditionSet: GQLConditionSet; @@ -5161,6 +5200,7 @@ export type GQLUserStrikeBucket = { export type GQLUserStrikeThreshold = { readonly __typename: 'UserStrikeThreshold'; + readonly actionParameters: Scalars['JSONObject']['output']; readonly actions: ReadonlyArray; readonly id: Scalars['String']['output']; readonly threshold: Scalars['Int']['output']; @@ -5873,6 +5913,23 @@ export type GQLActionsQuery = { readonly description?: string | null; readonly penalty: GQLUserPenaltySeverity; readonly applyUserStrikes?: boolean | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; } | { readonly __typename: 'EnqueueAuthorToMrtAction'; @@ -22076,6 +22133,23 @@ export type GQLReportingRuleFormOrgDataQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -22855,6 +22929,24 @@ type GQLRuleFormRuleFieldsFragmentContentRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -22878,6 +22970,7 @@ type GQLRuleFormRuleFieldsFragmentContentRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -22901,6 +22994,7 @@ type GQLRuleFormRuleFieldsFragmentContentRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -22924,6 +23018,7 @@ type GQLRuleFormRuleFieldsFragmentContentRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23125,6 +23220,24 @@ type GQLRuleFormRuleFieldsFragmentUserRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23148,6 +23261,7 @@ type GQLRuleFormRuleFieldsFragmentUserRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23171,6 +23285,7 @@ type GQLRuleFormRuleFieldsFragmentUserRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23194,6 +23309,7 @@ type GQLRuleFormRuleFieldsFragmentUserRuleFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23585,6 +23701,24 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23608,6 +23742,7 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23631,6 +23766,7 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23654,6 +23790,7 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23854,6 +23991,24 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23877,6 +24032,7 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23900,6 +24056,7 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23923,6 +24080,7 @@ export type GQLRuleQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly configuredParameters?: JsonObject | null; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -23951,6 +24109,23 @@ type GQLActionFragmentCustomActionFragment = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -24247,6 +24422,23 @@ export type GQLContentRuleFormConfigQuery = { readonly id: string; readonly name: string; readonly description?: string | null; + readonly parameters: ReadonlyArray<{ + readonly __typename: 'ActionParameter'; + readonly name: string; + readonly displayName: string; + readonly description?: string | null; + readonly type: GQLActionParameterType; + readonly required: boolean; + readonly min?: number | null; + readonly max?: number | null; + readonly maxLength?: number | null; + readonly defaultValue?: JsonValue | null; + readonly options?: ReadonlyArray<{ + readonly __typename: 'ActionParameterOption'; + readonly value: string; + readonly label: string; + }> | null; + }>; readonly itemTypes: ReadonlyArray< | { readonly __typename: 'ContentItemType'; @@ -24510,6 +24702,7 @@ export type GQLUserStrikeThresholdsQuery = { readonly id: string; readonly threshold: number; readonly actions: ReadonlyArray; + readonly actionParameters: JsonObject; }>; } | null; }; @@ -25957,6 +26150,22 @@ export const GQLRuleFormRuleFieldsFragmentFragmentDoc = gql` id name description + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -25968,6 +26177,7 @@ export const GQLRuleFormRuleFieldsFragmentFragmentDoc = gql` id name description + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -25979,6 +26189,7 @@ export const GQLRuleFormRuleFieldsFragmentFragmentDoc = gql` id name description + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -25990,6 +26201,7 @@ export const GQLRuleFormRuleFieldsFragmentFragmentDoc = gql` id name description + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -26007,6 +26219,21 @@ export const GQLActionFragmentFragmentDoc = gql` id name description + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } itemTypes { ... on ItemTypeBase { id @@ -28328,6 +28555,23 @@ export const GQLActionsDocument = gql` penalty applyUserStrikes } + ... on CustomAction { + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } + } } } me { @@ -42425,6 +42669,7 @@ export const GQLUserStrikeThresholdsDocument = gql` id threshold actions + actionParameters } userStrikeTTL } diff --git a/client/src/webpages/dashboard/actions/ActionsDashboard.tsx b/client/src/webpages/dashboard/actions/ActionsDashboard.tsx index 60ecf3a..bb28d96 100644 --- a/client/src/webpages/dashboard/actions/ActionsDashboard.tsx +++ b/client/src/webpages/dashboard/actions/ActionsDashboard.tsx @@ -40,6 +40,23 @@ gql` penalty applyUserStrikes } + ... on CustomAction { + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } + } } } me { diff --git a/client/src/webpages/dashboard/rules/rule_form/RuleActionParametersEditor.tsx b/client/src/webpages/dashboard/rules/rule_form/RuleActionParametersEditor.tsx new file mode 100644 index 0000000..ccb8133 --- /dev/null +++ b/client/src/webpages/dashboard/rules/rule_form/RuleActionParametersEditor.tsx @@ -0,0 +1,118 @@ +import { type GQLActionParameter } from '@/graphql/generated'; +import { Pencil } from 'lucide-react'; +import { useState } from 'react'; + +import { + findMissingRequiredParameters, + type ActionParameterValues, +} from '@/components/ActionParameterInputs'; +import ActionParametersModal, { + defaultValuesForParameters, +} from '@/components/ActionParametersModal'; + +export type ParameterizedActionOption = { + id: string; + name: string; + parameters: readonly GQLActionParameter[]; +}; + +type Props = { + // `value`/`onChange` are supplied by the wrapping `Form.Item`. + value?: Record; + onChange?: (next: Record) => void; + actions: readonly ParameterizedActionOption[]; + selectedActionIds: readonly string[]; + disabled?: boolean; +}; + +/** + * Lets an author configure the parameter values a proactive rule sends when it + * fires a parameterized action — proactive rules run with no moderator, so the + * values are set here. Each selected action with a spec gets a row that opens + * {@link ActionParametersModal}. + */ +export default function RuleActionParametersEditor({ + value, + onChange, + actions, + selectedActionIds, + disabled, +}: Props) { + const valueMap = value ?? {}; + const [editingActionId, setEditingActionId] = useState(null); + + const selectedParameterized = selectedActionIds + .map((actionId) => actions.find((a) => a.id === actionId)) + .filter( + (a): a is ParameterizedActionOption => + a != null && a.parameters.length > 0, + ); + + if (selectedParameterized.length === 0) { + return null; + } + + const valuesForAction = ( + action: ParameterizedActionOption, + ): ActionParameterValues => + valueMap[action.id] ?? defaultValuesForParameters(action.parameters); + + const editingAction = + editingActionId != null + ? (selectedParameterized.find((a) => a.id === editingActionId) ?? null) + : null; + + return ( +
+
+ Action parameters +
+ {selectedParameterized.map((action) => { + const current = valuesForAction(action); + const missing = findMissingRequiredParameters( + action.parameters, + current, + ); + return ( +
+ {action.name} + {missing.length > 0 ? ( + + Missing: {missing.join(', ')} + + ) : ( + Configured + )} + {!disabled && ( + + )} +
+ ); + })} + {editingAction != null && ( + { + onChange?.({ ...valueMap, [editingAction.id]: values }); + setEditingActionId(null); + }} + onCancel={() => setEditingActionId(null)} + /> + )} +
+ ); +} diff --git a/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx b/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx index af69578..c9ad7f6 100644 --- a/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx +++ b/client/src/webpages/dashboard/rules/rule_form/RuleForm.tsx @@ -15,6 +15,7 @@ import { useGQLRuleQuery, useGQLUpdateContentRuleMutation, useGQLUpdateUserRuleMutation, + type GQLRuleActionParameterValuesInput, } from '@/graphql/generated'; import CopyAlt from '@/icons/lni/Web and Technology/copy-alt.svg?react'; import TrashCan from '@/icons/lni/Web and Technology/trash-can.svg?react'; @@ -37,6 +38,7 @@ import FormSectionHeader from '../../components/FormSectionHeader'; import NameDescriptionInput from '../../components/NameDescriptionInput'; import PolicyDropdown from '../../components/PolicyDropdown'; import SubmitButton from '../../components/SubmitButton'; +import { defaultValuesForParameters } from '@/components/ActionParametersModal'; import { userHasPermissions } from '../../../../routing/permissions'; import useRouteQueryParams from '../../../../routing/useRouteQueryParams'; @@ -48,6 +50,9 @@ import { RuleFormConditionSet, RuleFormLeafCondition, } from '../types'; +import RuleActionParametersEditor, { + type ParameterizedActionOption, +} from './RuleActionParametersEditor'; import RuleFormCondition from './RuleFormCondition'; import { reducer, @@ -385,6 +390,22 @@ const RULE_FIELD_FRAGMENT = gql` id name description + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -396,6 +417,7 @@ const RULE_FIELD_FRAGMENT = gql` id name description + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -407,6 +429,7 @@ const RULE_FIELD_FRAGMENT = gql` id name description + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -418,6 +441,7 @@ const RULE_FIELD_FRAGMENT = gql` id name description + configuredParameters itemTypes { ... on ItemTypeBase { id @@ -455,6 +479,21 @@ export const ACTION_FRAGMENT = gql` id name description + parameters { + name + displayName + description + type + required + options { + value + label + } + min + max + maxLength + defaultValue + } itemTypes { ... on ItemTypeBase { id @@ -620,6 +659,38 @@ export default function RuleForm() { ); const policies = contentRuleFormConfigQueryData?.myOrg?.policies; + // Only CustomActions carry a parameter spec; these drive the per-action + // parameter-value editor shown beneath the action selector. + const parameterizedActions = useMemo( + () => + allActions.flatMap((action) => + action.__typename === 'CustomAction' && + (action.parameters?.length ?? 0) > 0 + ? [ + { + id: action.id, + name: action.name, + parameters: action.parameters ?? [], + }, + ] + : [], + ), + [allActions], + ); + + // Seed configured values from the rule being edited (or duplicated). + const initialActionParameters = useMemo< + Record> + >(() => { + const out: Record> = {}; + for (const action of rule?.actions ?? []) { + if ('configuredParameters' in action && action.configuredParameters) { + out[action.id] = action.configuredParameters as Record; + } + } + return out; + }, [rule]); + const { loading: matchingBanksQueryLoading, error: matchingBanksQueryError, @@ -903,6 +974,27 @@ export default function RuleForm() { ]); const isUserRule = state.ruleType === RuleType.USER; + // GraphQL input for selected parameterized actions, seeding spec defaults for + // any the author didn't edit so the server validates a complete payload. + const buildActionParametersInput = ( + values: any, + ): GQLRuleActionParameterValuesInput[] => { + const selected: string[] = values.actions ?? []; + const configured: Record< + string, + Record + > = values.actionParameters ?? {}; + return parameterizedActions + .filter((action) => selected.includes(action.id)) + .map((action) => ({ + actionId: action.id, + parameters: (configured[action.id] ?? + defaultValuesForParameters( + action.parameters, + )) as GQLRuleActionParameterValuesInput['parameters'], + })); + }; + const onCreateContentRule = async (values: any) => { dispatch({ type: RuleFormReducerActionType.DisableSubmitButton }); createContentRule({ @@ -914,6 +1006,7 @@ export default function RuleForm() { contentTypeIds: values.itemTypes, conditionSet: serializeConditionSet(state.conditionSet), actionIds: values.actions, + actionParameters: buildActionParametersInput(values), policyIds: state.policyIds, tags: state.tags, maxDailyActions: state.maxDailyActions, @@ -940,6 +1033,7 @@ export default function RuleForm() { contentTypeIds: values.itemTypes, conditionSet: serializeConditionSet(state.conditionSet), actionIds: values.actions, + actionParameters: buildActionParametersInput(values), policyIds: state.policyIds, tags: state.tags, maxDailyActions: state.maxDailyActions, @@ -965,6 +1059,7 @@ export default function RuleForm() { status: values.status, conditionSet: serializeConditionSet(state.conditionSet), actionIds: values.actions, + actionParameters: buildActionParametersInput(values), policyIds: state.policyIds, tags: state.tags, maxDailyActions: state.maxDailyActions, @@ -990,6 +1085,7 @@ export default function RuleForm() { status: values.status, conditionSet: serializeConditionSet(state.conditionSet), actionIds: values.actions, + actionParameters: buildActionParametersInput(values), policyIds: state.policyIds, tags: state.tags, maxDailyActions: state.maxDailyActions, @@ -1457,6 +1553,23 @@ export default function RuleForm() { ))} + prev.actions !== cur.actions} + > + {() => ( + + + + )} + ); diff --git a/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx b/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx index 459e236..ebe9f9e 100644 --- a/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx @@ -5,14 +5,30 @@ import { useGQLSetAllUserStrikeThresholdMutation, useGQLUpdateUserStrikeTtlMutation, useGQLUserStrikeThresholdsQuery, + type GQLActionParameter, + type GQLSetUserStrikeThresholdInput, } from '@/graphql/generated'; import { gql } from '@apollo/client'; import { Check, Pencil, PlusIcon, Trash2 } from 'lucide-react'; import { useState } from 'react'; +import { + findMissingRequiredParameters, + type ActionParameterValues, +} from '@/components/ActionParameterInputs'; +import ActionParametersModal, { + defaultValuesForParameters, +} from '@/components/ActionParametersModal'; import CoopSelect from '@/components/common/CoopSelect'; import FullScreenLoading from '@/components/common/FullScreenLoading'; +// A threshold-selectable action plus its parameter spec (CustomActions only). +type ThresholdActionOption = { + id: string; + name: string; + parameters: readonly GQLActionParameter[]; +}; + gql` query UserStrikeThresholds { myOrg { @@ -20,6 +36,7 @@ gql` id threshold actions + actionParameters } userStrikeTTL } @@ -44,6 +61,8 @@ type Threshold = { id: string; threshold: number; actions: string[]; + // Configured parameter values per action: `actionId -> { name -> value }`. + actionParameters: Record; }; export default function ThresholdsTab() { @@ -74,7 +93,14 @@ export default function ThresholdsTab() { const thresholds = data?.myOrg?.userStrikeThresholds; - const orgActions = actionsData?.myOrg?.actions ?? []; + const orgActions: ThresholdActionOption[] = ( + actionsData?.myOrg?.actions ?? [] + ).map((action) => ({ + id: action.id, + name: action.name, + parameters: + action.__typename === 'CustomAction' ? (action.parameters ?? []) : [], + })); if (error || actionsError) { throw new Error('Error fetching data'); @@ -94,12 +120,16 @@ export default function ThresholdsTab() { id: t.id, threshold: t.threshold, actions: t.actions.map((a) => a), + actionParameters: (t.actionParameters ?? {}) as Record< + string, + ActionParameterValues + >, }; }) .sort((a, b) => a.threshold - b.threshold) : [] } - orgActions={[...orgActions]} + orgActions={orgActions} setThresholds={async (thresholds) => { await setUserStrikeThresholds({ variables: { @@ -108,6 +138,13 @@ export default function ThresholdsTab() { // We don't need to send the ids threshold: t.threshold, actions: t.actions, + // Only persist values for actions still attached to the + // threshold so removing an action drops its config. + actionParameters: Object.fromEntries( + Object.entries(t.actionParameters ?? {}).filter(([id]) => + t.actions.includes(id), + ), + ) as GQLSetUserStrikeThresholdInput['actionParameters'], })), }, }, @@ -244,7 +281,7 @@ function StrikeTTLForm(props: { function ThresholdForm(props: { thresholdSet: Threshold[]; - orgActions: { id: string; name: string }[]; + orgActions: ThresholdActionOption[]; setThresholds: (thresholds: Threshold[]) => void; }) { const { thresholdSet, orgActions, setThresholds } = props; @@ -352,6 +389,7 @@ function ThresholdForm(props: { : thresholdFormState[thresholdFormState.length - 1] .threshold + 1, actions: [], + actionParameters: {}, }; setThresholdFormState([...thresholdFormState, newThreshold]); }} @@ -370,12 +408,36 @@ function ThresholdForm(props: { function EditableThreshold(props: { thresholdRule: Threshold; editing: boolean; - actionOptions: readonly { id: string; name: string }[]; + actionOptions: readonly ThresholdActionOption[]; setThreshold: (threshold: Threshold) => void; deleteThreshold: (threshold: Threshold) => void; }) { const { thresholdRule, actionOptions, setThreshold, deleteThreshold } = props; + // The action whose parameters modal is open, or null when closed. + const [editingParamsActionId, setEditingParamsActionId] = useState< + string | null + >(null); + + // Selected actions that declare parameters — these get an "Edit" affordance. + const parameterizedSelected = thresholdRule.actions + .map((actionId) => actionOptions.find((a) => a.id === actionId)) + .filter( + (a): a is ThresholdActionOption => a != null && a.parameters.length > 0, + ); + + const valuesForAction = ( + action: ThresholdActionOption, + ): ActionParameterValues => + thresholdRule.actionParameters[action.id] ?? + defaultValuesForParameters(action.parameters); + + const editingAction = + editingParamsActionId != null + ? (parameterizedSelected.find((a) => a.id === editingParamsActionId) ?? + null) + : null; + return (
@@ -423,11 +485,13 @@ function EditableThreshold(props: { : [] } onDeselect={(e) => { - // find the index of e in the thresholdRule.actions array and remove - // it + // Remove the action and drop any parameter values configured for it. + const { [e]: _removed, ...remainingParameters } = + thresholdRule.actionParameters; setThreshold({ ...thresholdRule, actions: thresholdRule.actions.filter((it) => it !== e), + actionParameters: remainingParameters, }); }} onSelect={(e) => { @@ -459,6 +523,62 @@ function EditableThreshold(props: {
) : null}
+ {parameterizedSelected.length > 0 ? ( +
+
Action parameters
+ {parameterizedSelected.map((action) => { + const missing = findMissingRequiredParameters( + action.parameters, + valuesForAction(action), + ); + return ( +
+ {action.name} + {missing.length > 0 ? ( + + Missing: {missing.join(', ')} + + ) : ( + Configured + )} + {props.editing ? ( + + ) : null} +
+ ); + })} +
+ ) : null} + {editingAction != null ? ( + { + setThreshold({ + ...thresholdRule, + actionParameters: { + ...thresholdRule.actionParameters, + [editingAction.id]: values, + }, + }); + setEditingParamsActionId(null); + }} + onCancel={() => setEditingParamsActionId(null)} + /> + ) : null} ); } diff --git a/db/src/scripts/api-server-pg/2026.06.14T02.33.06.add-action-parameters-to-rules-and-strikes.sql b/db/src/scripts/api-server-pg/2026.06.14T02.33.06.add-action-parameters-to-rules-and-strikes.sql new file mode 100644 index 0000000..284579a --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.06.14T02.33.06.add-action-parameters-to-rules-and-strikes.sql @@ -0,0 +1,35 @@ +-- Parameterized actions (see #377) let an action declare typed parameters that +-- are filled in at execution time. On the MRT a moderator supplies the values +-- interactively; proactive rules and user-strike thresholds run automatically +-- with no moderator, so the values have to be configured up front, alongside +-- the action attachment. +-- +-- This migration adds a place to persist those configured values: +-- +-- public.rules_and_actions.action_parameters +-- The `name -> value` map sent when this rule fires this action. Scoped to +-- the (rule_id, action_id) attachment so the same action can carry +-- different values on different rules. Empty `'{}'` when the action takes +-- no parameters. +-- +-- public.user_strike_thresholds.action_parameters +-- A threshold fans out to several actions (the `actions` array), so this is +-- a map of `action_id -> { name -> value }`. Empty `'{}'` when none of the +-- threshold's actions take parameters. +-- +-- Both default to `'{}'` so existing rows stay valid without a backfill and +-- older code paths that don't send the column keep working unchanged. +-- +-- `rules_and_actions` is a temporal table: the `versioning()` trigger copies +-- each old row into `rules_and_actions_history`. The history table must carry +-- the same column or the trigger's column list drifts, so we add it there too +-- (nullable — history rows predating this column legitimately have no value). + +ALTER TABLE public.rules_and_actions + ADD COLUMN IF NOT EXISTS action_parameters jsonb NOT NULL DEFAULT '{}'::jsonb; + +ALTER TABLE public.rules_and_actions_history + ADD COLUMN IF NOT EXISTS action_parameters jsonb; + +ALTER TABLE public.user_strike_thresholds + ADD COLUMN IF NOT EXISTS action_parameters jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/server/graphql/datasources/RuleApi.ts b/server/graphql/datasources/RuleApi.ts index a8cf262..a80cf95 100644 --- a/server/graphql/datasources/RuleApi.ts +++ b/server/graphql/datasources/RuleApi.ts @@ -3,6 +3,7 @@ import { type Exception } from '@opentelemetry/api'; import { makeEnumLike } from '@roostorg/coop-types'; import { type Kysely } from 'kysely'; +import { type JsonObject } from 'type-fest'; import { uid } from 'uid'; import { inject, type Dependencies } from '../../iocContainer/index.js'; @@ -15,7 +16,9 @@ import { makeRuleHasRunningBacktestsError, makeRuleIsMissingContentTypeError, makeRuleNameExistsError, + parseStoredParameters, RuleType, + validateActionParameterValues, type Condition, type ConditionInput, type ConditionSet, @@ -38,7 +41,7 @@ import { jsonStringify, tryJsonParse, } from '../../utils/encoding.js'; -import { makeNotFoundError } from '../../utils/errors.js'; +import { makeBadRequestError, makeNotFoundError } from '../../utils/errors.js'; import { isUniqueViolationError } from '../../utils/kysely.js'; import { makeKyselyTransactionWithRetry, @@ -370,6 +373,46 @@ class RuleAPI { ); } + // Validates configured parameter values against each action's spec and + // returns the `actionId -> values` map persistence expects. Throws (surfaced + // to the admin) on invalid values; ignores entries for unattached actions. + private async buildRuleActionParametersMap( + orgId: string, + actionIds: readonly string[], + actionParameters: + | readonly { actionId: string; parameters: unknown }[] + | null + | undefined, + ): Promise | undefined> { + if (actionIds.length === 0) { + return undefined; + } + const paramsByActionId = new Map( + (actionParameters ?? []).map((it) => [it.actionId, it.parameters]), + ); + const actions = await this.moderationConfigService.getActions({ + orgId, + ids: [...actionIds], + }); + const out = new Map(); + for (const action of actions) { + const spec = parseStoredParameters( + action.actionType === 'CUSTOM_ACTION' + ? action.customMrtApiParams + : null, + ); + if (spec.length === 0) { + continue; + } + const rawValues = paramsByActionId.get(action.id) ?? null; + const validated = validateActionParameterValues(spec, rawValues); + if (Object.keys(validated).length > 0) { + out.set(action.id, validated as JsonObject); + } + } + return out.size > 0 ? out : undefined; + } + private async createRule( input: | (GQLCreateContentRuleInput & { ruleType: typeof RuleType.CONTENT }) @@ -383,6 +426,7 @@ class RuleAPI { status, conditionSet, actionIds, + actionParameters, policyIds, tags, ruleType, @@ -391,6 +435,12 @@ class RuleAPI { parentId, } = input; + const actionParametersMap = await this.buildRuleActionParametersMap( + orgId, + actionIds, + actionParameters, + ); + const contentTypeIds: readonly string[] = input.ruleType === RuleType.CONTENT ? input.contentTypeIds : []; if (ruleType === RuleType.CONTENT && contentTypeIds.length === 0) { @@ -421,6 +471,7 @@ class RuleAPI { ruleType, parentId, actionIds, + actionParameters: actionParametersMap, policyIds, contentTypeIds, }); @@ -475,6 +526,7 @@ class RuleAPI { status, conditionSet, actionIds, + actionParameters, policyIds, tags, ruleType, @@ -527,6 +579,20 @@ class RuleAPI { await this.validateSignalsAllowedInAutomatedRules(conditionSet, orgId); } + // Parameters are persisted alongside the action attachments, so updating + // them without also setting actionIds would silently drop them. + if (actionParameters != null && actionIds == null) { + throw makeBadRequestError( + 'actionParameters can only be updated when actionIds is also provided', + { shouldErrorSpan: true }, + ); + } + const actionParametersMap = await this.buildRuleActionParametersMap( + orgId, + actionIds ?? [], + actionParameters, + ); + // Before we actually send any updates (which will happen as soon as we call // setXXX to set the associations), we need to make sure that there are no // active backtests for this rule because, if there are, we should fail the @@ -562,6 +628,7 @@ class RuleAPI { expirationTime: normalizeExpirationInput(expirationTime), parentId, actionIds: actionIds ?? undefined, + actionParameters: actionParametersMap, policyIds: policyIds ?? undefined, contentTypeIds: contentTypeIds ?? undefined, }); diff --git a/server/graphql/datasources/buildGraphqlRuleParent.ts b/server/graphql/datasources/buildGraphqlRuleParent.ts index 2a031f7..6107dc9 100644 --- a/server/graphql/datasources/buildGraphqlRuleParent.ts +++ b/server/graphql/datasources/buildGraphqlRuleParent.ts @@ -23,6 +23,19 @@ export function buildGraphqlRuleParent( findUserByIdAndOrg: FindUserByIdAndOrg; }, ): GraphQLRuleParent { + // getActions and getActionParameters resolve from the same joined read, so + // share one lazy promise to avoid querying the rule's actions twice. + let actionsWithParameters: + | ReturnType + | undefined; + const getActionsWithParameters = async () => { + actionsWithParameters ??= deps.moderationConfigService.getActionsForRuleId({ + orgId: plain.orgId, + ruleId: plain.id, + }); + return actionsWithParameters; + }; + return { ...plain, async getCreator() { @@ -36,10 +49,14 @@ export function buildGraphqlRuleParent( return user; }, async getActions() { - return deps.moderationConfigService.getActionsForRuleId({ - orgId: plain.orgId, - ruleId: plain.id, - }); + return (await getActionsWithParameters()).map((it) => it.action); + }, + async getActionParameters() { + const withParams = await getActionsWithParameters(); + return withParams.map((it) => ({ + actionId: it.action.id, + parameters: it.parameters, + })); }, async getPolicies() { const byRule = await deps.moderationConfigService.getPoliciesByRuleIds([ diff --git a/server/graphql/datasources/ruleKyselyPersistence.ts b/server/graphql/datasources/ruleKyselyPersistence.ts index d0367b9..4934ff4 100644 --- a/server/graphql/datasources/ruleKyselyPersistence.ts +++ b/server/graphql/datasources/ruleKyselyPersistence.ts @@ -1,4 +1,5 @@ import { sql, type Insertable, type Kysely, type Updateable } from 'kysely'; +import { type JsonObject } from 'type-fest'; import { type CombinedPg } from '../../services/combinedDbTypes.js'; import { type BacktestStatusDb } from '../../services/coreAppTables.js'; @@ -23,6 +24,9 @@ import { type GraphQLUserParent } from './userKyselyPersistence.js'; export type GraphQLRuleParent = PlainRuleWithLatestVersion & { getCreator(): Promise; getActions(): Promise; + getActionParameters(): Promise< + { actionId: string; parameters: JsonObject }[] + >; getPolicies(): Promise; }; @@ -81,6 +85,8 @@ async function replaceRuleActions( trx: Kysely, ruleId: string, actionIds: readonly string[], + // Per-action parameters; actions absent from the map are persisted as `{}`. + actionParameters?: ReadonlyMap, ) { await trx .deleteFrom('public.rules_and_actions') @@ -92,7 +98,11 @@ async function replaceRuleActions( await trx .insertInto('public.rules_and_actions') .values( - actionIds.map((actionId) => ({ rule_id: ruleId, action_id: actionId })), + actionIds.map((actionId) => ({ + rule_id: ruleId, + action_id: actionId, + action_parameters: actionParameters?.get(actionId) ?? {}, + })), ) .execute(); } @@ -162,6 +172,7 @@ export async function kyselyCreateRule( ruleType: RuleType; parentId: string | null | undefined; actionIds: readonly string[]; + actionParameters?: ReadonlyMap; policyIds: readonly string[]; contentTypeIds: readonly string[]; }, @@ -203,7 +214,12 @@ export async function kyselyCreateRule( }; await trx.insertInto('public.rules').values(ruleValues).execute(); - await replaceRuleActions(trx, input.id, input.actionIds); + await replaceRuleActions( + trx, + input.id, + input.actionIds, + input.actionParameters, + ); await replaceRulePolicies(trx, input.id, input.policyIds); if (input.ruleType === RuleType.CONTENT) { await replaceRuleItemTypes(trx, input.id, input.contentTypeIds); @@ -225,6 +241,7 @@ export async function kyselyUpdateRule( expirationTime: Date | null | undefined; parentId: string | null | undefined; actionIds: readonly string[] | undefined; + actionParameters?: ReadonlyMap; policyIds: readonly string[] | undefined; contentTypeIds: readonly string[] | undefined; }, @@ -322,7 +339,12 @@ export async function kyselyUpdateRule( } if (input.actionIds != null) { - await replaceRuleActions(trx, input.id, input.actionIds); + await replaceRuleActions( + trx, + input.id, + input.actionIds, + input.actionParameters, + ); } if (input.policyIds != null) { await replaceRulePolicies(trx, input.id, input.policyIds); diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 3ebcb51..0ce3557 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -139,6 +139,11 @@ export type GQLAction = export type GQLActionBase = { readonly applyUserStrikes?: Maybe; + /** + * Configured parameter values for this action in the context of a rule or + * strike threshold. Null when resolved outside of a rule context. + */ + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -660,6 +665,8 @@ export type GQLContentManualReviewJobPayload = { export type GQLContentRule = GQLRule & { readonly __typename?: 'ContentRule'; + /** @deprecated Use configuredParameters on each Action instead. */ + readonly actionParameters: ReadonlyArray; readonly actions: ReadonlyArray; readonly backtests: ReadonlyArray; readonly conditionSet: GQLConditionSet; @@ -808,6 +815,9 @@ export type GQLCreateContentItemTypeInput = { export type GQLCreateContentRuleInput = { readonly actionIds: ReadonlyArray; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly conditionSet: GQLConditionSetInput; readonly contentTypeIds: ReadonlyArray; readonly description?: InputMaybe; @@ -915,6 +925,9 @@ export type GQLCreateUserItemTypeInput = { export type GQLCreateUserRuleInput = { readonly actionIds: ReadonlyArray; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly conditionSet: GQLConditionSetInput; readonly description?: InputMaybe; readonly expirationTime?: InputMaybe; @@ -936,6 +949,7 @@ export type GQLCustomAction = GQLActionBase & { readonly callbackUrl: Scalars['String']['output']; readonly callbackUrlBody?: Maybe; readonly callbackUrlHeaders?: Maybe; + readonly configuredParameters?: Maybe; /** * Deprecated alias for `parameters` retained for back-compat with the * initial MRT-only parameter implementation. New consumers should read @@ -1172,6 +1186,7 @@ export type GQLDisabledInfoInput = { export type GQLEnqueueAuthorToMrtAction = GQLActionBase & { readonly __typename?: 'EnqueueAuthorToMrtAction'; readonly applyUserStrikes: Scalars['Boolean']['output']; + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -1184,6 +1199,7 @@ export type GQLEnqueueAuthorToMrtAction = GQLActionBase & { export type GQLEnqueueToMrtAction = GQLActionBase & { readonly __typename?: 'EnqueueToMrtAction'; readonly applyUserStrikes?: Maybe; + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -1196,6 +1212,7 @@ export type GQLEnqueueToMrtAction = GQLActionBase & { export type GQLEnqueueToNcmecAction = GQLActionBase & { readonly __typename?: 'EnqueueToNcmecAction'; readonly applyUserStrikes?: Maybe; + readonly configuredParameters?: Maybe; readonly description?: Maybe; readonly id: Scalars['ID']['output']; readonly itemTypes: ReadonlyArray; @@ -4160,6 +4177,8 @@ export const GQLRoutingRuleStatus = { export type GQLRoutingRuleStatus = (typeof GQLRoutingRuleStatus)[keyof typeof GQLRoutingRuleStatus]; export type GQLRule = { + /** @deprecated Use configuredParameters on each Action instead. */ + readonly actionParameters: ReadonlyArray; readonly actions: ReadonlyArray; readonly backtests: ReadonlyArray; readonly conditionSet: GQLConditionSet; @@ -4182,6 +4201,17 @@ export type GQLRuleBacktestsArgs = { ids?: InputMaybe>; }; +export type GQLRuleActionParameterValues = { + readonly __typename?: 'RuleActionParameterValues'; + readonly actionId: Scalars['ID']['output']; + readonly parameters: Scalars['JSONObject']['output']; +}; + +export type GQLRuleActionParameterValuesInput = { + readonly actionId: Scalars['ID']['input']; + readonly parameters: Scalars['JSONObject']['input']; +}; + export const GQLRuleEnvironment = { Background: 'BACKGROUND', Backtest: 'BACKTEST', @@ -4364,6 +4394,7 @@ export type GQLSetPluginIntegrationConfigInput = { }; export type GQLSetUserStrikeThresholdInput = { + readonly actionParameters?: InputMaybe; readonly actions: ReadonlyArray; readonly threshold: Scalars['Int']['input']; }; @@ -4806,6 +4837,9 @@ export type GQLUpdateContentItemTypeInput = { export type GQLUpdateContentRuleInput = { readonly actionIds?: InputMaybe>; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly cancelRunningBacktests?: InputMaybe; readonly conditionSet?: InputMaybe; readonly contentTypeIds?: InputMaybe>; @@ -4979,6 +5013,9 @@ export type GQLUpdateUserItemTypeInput = { export type GQLUpdateUserRuleInput = { readonly actionIds?: InputMaybe>; + readonly actionParameters?: InputMaybe< + ReadonlyArray + >; readonly cancelRunningBacktests?: InputMaybe; readonly conditionSet?: InputMaybe; readonly description?: InputMaybe; @@ -5180,6 +5217,8 @@ export const GQLUserRole = { export type GQLUserRole = (typeof GQLUserRole)[keyof typeof GQLUserRole]; export type GQLUserRule = GQLRule & { readonly __typename?: 'UserRule'; + /** @deprecated Use configuredParameters on each Action instead. */ + readonly actionParameters: ReadonlyArray; readonly actions: ReadonlyArray; readonly backtests: ReadonlyArray; readonly conditionSet: GQLConditionSet; @@ -5229,6 +5268,7 @@ export type GQLUserStrikeBucket = { export type GQLUserStrikeThreshold = { readonly __typename?: 'UserStrikeThreshold'; + readonly actionParameters: Scalars['JSONObject']['output']; readonly actions: ReadonlyArray; readonly id: Scalars['String']['output']; readonly threshold: Scalars['Int']['output']; @@ -6372,6 +6412,8 @@ export type GQLResolversTypes = { RoutingRuleNameExistsError: ResolverTypeWrapper; RoutingRuleStatus: GQLRoutingRuleStatus; Rule: ResolverTypeWrapper; + RuleActionParameterValues: ResolverTypeWrapper; + RuleActionParameterValuesInput: GQLRuleActionParameterValuesInput; RuleEnvironment: GQLRuleEnvironment; RuleExecutionEnqueueSourceInfo: ResolverTypeWrapper< Omit & { @@ -7064,6 +7106,8 @@ export type GQLResolversParentTypes = { RoutingRule: RoutingRuleWithoutVersion; RoutingRuleNameExistsError: GQLRoutingRuleNameExistsError; Rule: GraphQLRuleParent; + RuleActionParameterValues: GQLRuleActionParameterValues; + RuleActionParameterValuesInput: GQLRuleActionParameterValuesInput; RuleExecutionEnqueueSourceInfo: Omit< GQLRuleExecutionEnqueueSourceInfo, 'rules' @@ -8122,6 +8166,11 @@ export type GQLContentRuleResolvers< ParentType extends GQLResolversParentTypes['ContentRule'] = GQLResolversParentTypes['ContentRule'], > = { + actionParameters?: Resolver< + ReadonlyArray, + ParentType, + ContextType + >; actions?: Resolver< ReadonlyArray, ParentType, @@ -8437,6 +8486,11 @@ export type GQLCustomActionResolvers< ParentType, ContextType >; + configuredParameters?: Resolver< + Maybe, + ParentType, + ContextType + >; customMrtApiParams?: Resolver< ReadonlyArray>, ParentType, @@ -8773,6 +8827,11 @@ export type GQLEnqueueAuthorToMrtActionResolvers< ParentType, ContextType >; + configuredParameters?: Resolver< + Maybe, + ParentType, + ContextType + >; description?: Resolver< Maybe, ParentType, @@ -8809,6 +8868,11 @@ export type GQLEnqueueToMrtActionResolvers< ParentType, ContextType >; + configuredParameters?: Resolver< + Maybe, + ParentType, + ContextType + >; description?: Resolver< Maybe, ParentType, @@ -8845,6 +8909,11 @@ export type GQLEnqueueToNcmecActionResolvers< ParentType, ContextType >; + configuredParameters?: Resolver< + Maybe, + ParentType, + ContextType + >; description?: Resolver< Maybe, ParentType, @@ -13392,6 +13461,19 @@ export type GQLRuleResolvers< >; }; +export type GQLRuleActionParameterValuesResolvers< + ContextType = Context, + ParentType extends GQLResolversParentTypes['RuleActionParameterValues'] = + GQLResolversParentTypes['RuleActionParameterValues'], +> = { + actionId?: Resolver; + parameters?: Resolver< + GQLResolversTypes['JSONObject'], + ParentType, + ContextType + >; +}; + export type GQLRuleExecutionEnqueueSourceInfoResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['RuleExecutionEnqueueSourceInfo'] = @@ -14809,6 +14891,11 @@ export type GQLUserRuleResolvers< ParentType extends GQLResolversParentTypes['UserRule'] = GQLResolversParentTypes['UserRule'], > = { + actionParameters?: Resolver< + ReadonlyArray, + ParentType, + ContextType + >; actions?: Resolver< ReadonlyArray, ParentType, @@ -14917,6 +15004,11 @@ export type GQLUserStrikeThresholdResolvers< ParentType extends GQLResolversParentTypes['UserStrikeThreshold'] = GQLResolversParentTypes['UserStrikeThreshold'], > = { + actionParameters?: Resolver< + GQLResolversTypes['JSONObject'], + ParentType, + ContextType + >; actions?: Resolver< ReadonlyArray, ParentType, @@ -15237,6 +15329,7 @@ export type GQLResolvers = { RoutingRule?: GQLRoutingRuleResolvers; RoutingRuleNameExistsError?: GQLRoutingRuleNameExistsErrorResolvers; Rule?: GQLRuleResolvers; + RuleActionParameterValues?: GQLRuleActionParameterValuesResolvers; RuleExecutionEnqueueSourceInfo?: GQLRuleExecutionEnqueueSourceInfoResolvers; RuleExecutionResult?: GQLRuleExecutionResultResolvers; RuleExecutionResultEdge?: GQLRuleExecutionResultEdgeResolvers; diff --git a/server/graphql/modules/action.ts b/server/graphql/modules/action.ts index 62b51e9..4286697 100644 --- a/server/graphql/modules/action.ts +++ b/server/graphql/modules/action.ts @@ -1,3 +1,6 @@ +/* eslint-disable max-lines */ +import { type JsonObject } from 'type-fest'; + import { parseStoredParameters } from '../../services/moderationConfigService/index.js'; import { isCoopErrorOfType } from '../../utils/errors.js'; import { assertUnreachable } from '../../utils/misc.js'; @@ -25,6 +28,11 @@ const typeDefs = /* GraphQL */ ` applyUserStrikes: Boolean itemTypes: [ItemType!]! parameters: [ActionParameter!]! + """ + Configured parameter values for this action in the context of a rule or + strike threshold. Null when resolved outside of a rule context. + """ + configuredParameters: JSONObject } enum ActionParameterType { @@ -103,6 +111,7 @@ const typeDefs = /* GraphQL */ ` callbackUrlBody: JSONObject applyUserStrikes: Boolean parameters: [ActionParameter!]! + configuredParameters: JSONObject """ Deprecated alias for \`parameters\` retained for back-compat with the initial MRT-only parameter implementation. New consumers should read @@ -127,6 +136,7 @@ const typeDefs = /* GraphQL */ ` itemTypes: [ItemType!]! applyUserStrikes: Boolean parameters: [ActionParameter!]! + configuredParameters: JSONObject } type EnqueueToNcmecAction implements ActionBase { @@ -138,6 +148,7 @@ const typeDefs = /* GraphQL */ ` itemTypes: [ItemType!]! applyUserStrikes: Boolean parameters: [ActionParameter!]! + configuredParameters: JSONObject } type EnqueueAuthorToMrtAction implements ActionBase { @@ -149,6 +160,7 @@ const typeDefs = /* GraphQL */ ` itemTypes: [ItemType!]! applyUserStrikes: Boolean! parameters: [ActionParameter!]! + configuredParameters: JSONObject } union Action = @@ -307,10 +319,22 @@ function readRawParameters(parent: unknown): unknown { ); } +// Populated by rule.ts resolvers when the action is resolved in a rule context. +function readConfiguredParameters(parent: unknown): JsonObject | null { + if (typeof parent !== 'object' || parent === null) return null; + return ( + (parent as { configuredParameters?: JsonObject }).configuredParameters ?? + null + ); +} + const CustomAction: GQLCustomActionResolvers = { parameters(parent) { return projectParameters(parent.customMrtApiParams); }, + configuredParameters(parent) { + return readConfiguredParameters(parent); + }, customMrtApiParams(parent) { return Array.isArray(parent.customMrtApiParams) ? (parent.customMrtApiParams as readonly GQLCustomMrtApiParamSpec[]) @@ -332,6 +356,9 @@ const EnqueueAuthorToMrtAction: GQLEnqueueAuthorToMrtActionResolvers = { parameters(parent) { return projectParameters(readRawParameters(parent)); }, + configuredParameters(parent) { + return readConfiguredParameters(parent); + }, async itemTypes(action, _, context) { const user = context.getUser(); if (user == null) { @@ -348,6 +375,9 @@ const EnqueueToMrtAction: GQLEnqueueToMrtActionResolvers = { parameters(parent) { return projectParameters(readRawParameters(parent)); }, + configuredParameters(parent) { + return readConfiguredParameters(parent); + }, async itemTypes(action, _, context) { const user = context.getUser(); if (user == null) { @@ -364,6 +394,9 @@ const EnqueueToNcmecAction: GQLEnqueueToNcmecActionResolvers = { parameters(parent) { return projectParameters(readRawParameters(parent)); }, + configuredParameters(parent) { + return readConfiguredParameters(parent); + }, async itemTypes(action, _, context) { const user = context.getUser(); if (user == null) { diff --git a/server/graphql/modules/org.ts b/server/graphql/modules/org.ts index ba1b043..73af750 100644 --- a/server/graphql/modules/org.ts +++ b/server/graphql/modules/org.ts @@ -1,8 +1,12 @@ /* eslint-disable max-lines */ import { GraphQLError } from 'graphql'; -import { type JsonObject } from 'type-fest'; +import { type JsonObject, type JsonValue } from 'type-fest'; +import { + parseStoredParameters, + validateActionParameterValues, +} from '../../services/moderationConfigService/index.js'; import { filterDecisionsToFailedSubmissions } from '../../services/ncmecService/index.js'; import { UserPermission } from '../../services/userManagementService/index.js'; import { __throw } from '../../utils/misc.js'; @@ -105,11 +109,14 @@ const typeDefs = /* GraphQL */ ` id: String! threshold: Int! actions: [ID!]! + # Per-action configured parameter values: actionId -> name -> value. + actionParameters: JSONObject! } input SetUserStrikeThresholdInput { threshold: Int! actions: [String!]! + actionParameters: JSONObject } input SetAllUserStrikeThresholdsInput { @@ -245,6 +252,62 @@ export async function resolveOrgActions( : actions.filter((it) => it.actionType !== 'ENQUEUE_TO_NCMEC'); } +// Validates each threshold's configured parameter values against the actions' +// specs, dropping values for actions no longer attached. Throws (surfaced to +// the admin) on any invalid value. +async function validateUserStrikeThresholdActionParameters( + context: Context, + orgId: string, + thresholds: readonly { + threshold: number; + actions: readonly string[]; + actionParameters?: JsonObject | null; + }[], +): Promise< + { threshold: number; actions: string[]; actionParameters: JsonObject }[] +> { + const allActionIds = [...new Set(thresholds.flatMap((t) => [...t.actions]))]; + const actions = + allActionIds.length > 0 + ? await context.services.ModerationConfigService.getActions({ + orgId, + ids: allActionIds, + }) + : []; + const specByActionId = new Map( + actions.map((a) => [ + a.id, + parseStoredParameters( + a.actionType === 'CUSTOM_ACTION' ? a.customMrtApiParams : null, + ), + ]), + ); + + return thresholds.map((t) => { + const raw: JsonObject = t.actionParameters ?? {}; + const validated: JsonObject = {}; + // Iterate the attached actions (not just keys in `raw`) so required-value + // checks run even for actions the client omitted from `actionParameters`. + for (const actionId of t.actions) { + const spec = specByActionId.get(actionId) ?? []; + const rawValues = Object.prototype.hasOwnProperty.call(raw, actionId) + ? raw[actionId] + : undefined; + const values = validateActionParameterValues(spec, rawValues); + if (Object.keys(values).length > 0) { + // Validated values are JSON by construction (the validator only emits + // coerced primitives/arrays). + validated[actionId] = values as JsonValue; + } + } + return { + threshold: t.threshold, + actions: [...t.actions], + actionParameters: validated, + }; + }); +} + const Org: GQLOrgResolvers = { actions: resolveOrgActions, async contentTypes(org, _, context) { @@ -847,9 +910,15 @@ const Mutation: GQLMutationResolvers = { throw unauthenticatedError('User required.'); } + const thresholds = await validateUserStrikeThresholdActionParameters( + context, + user.orgId, + params.input.thresholds, + ); + await context.services.ModerationConfigService.setAllUserStrikeThresholds({ orgId: user.orgId, - thresholds: params.input.thresholds, + thresholds, }); return gqlSuccessResult({}, 'SetAllUserStrikeThresholdsSuccessResponse'); }, diff --git a/server/graphql/modules/rule.ts b/server/graphql/modules/rule.ts index 9a6be26..f5eb62d 100644 --- a/server/graphql/modules/rule.ts +++ b/server/graphql/modules/rule.ts @@ -1,5 +1,7 @@ /* eslint-disable max-lines */ +import { type JsonObject } from 'type-fest'; + import { isConditionSet } from '../../condition_evaluator/condition.js'; import { getNameForDerivedField, @@ -46,6 +48,11 @@ const typeDefs = /* GraphQL */ ` deleteRule(id: ID!): Boolean } + type RuleActionParameterValues { + actionId: ID! + parameters: JSONObject! + } + interface Rule { id: ID! parentId: ID @@ -57,6 +64,8 @@ const typeDefs = /* GraphQL */ ` status: RuleStatus! conditionSet: ConditionSet! actions: [Action!]! + actionParameters: [RuleActionParameterValues!]! + @deprecated(reason: "Use configuredParameters on each Action instead.") policies: [Policy!]! tags: [String] # GraphQL doesn't support BIGINT, so this must be a Float @@ -77,6 +86,8 @@ const typeDefs = /* GraphQL */ ` status: RuleStatus! conditionSet: ConditionSet! actions: [Action!]! + actionParameters: [RuleActionParameterValues!]! + @deprecated(reason: "Use configuredParameters on each Action instead.") policies: [Policy!]! tags: [String] maxDailyActions: Float @@ -98,6 +109,8 @@ const typeDefs = /* GraphQL */ ` status: RuleStatus! conditionSet: ConditionSet! actions: [Action!]! + actionParameters: [RuleActionParameterValues!]! + @deprecated(reason: "Use configuredParameters on each Action instead.") policies: [Policy!]! tags: [String] maxDailyActions: Float @@ -286,6 +299,11 @@ const typeDefs = /* GraphQL */ ` ERRORED } + input RuleActionParameterValuesInput { + actionId: ID! + parameters: JSONObject! + } + input CreateContentRuleInput { name: String! description: String @@ -293,6 +311,7 @@ const typeDefs = /* GraphQL */ ` contentTypeIds: [ID!]! conditionSet: ConditionSetInput! actionIds: [ID!]! + actionParameters: [RuleActionParameterValuesInput!] policyIds: [ID!]! tags: [String!]! # GraphQL doesn't support BIGINT, so this must be a Float @@ -309,6 +328,7 @@ const typeDefs = /* GraphQL */ ` contentTypeIds: [ID!] conditionSet: ConditionSetInput actionIds: [ID!] + actionParameters: [RuleActionParameterValuesInput!] policyIds: [ID!] tags: [String!] # GraphQL doesn't support BIGINT, so this must be a Float @@ -324,6 +344,7 @@ const typeDefs = /* GraphQL */ ` status: RuleStatus! conditionSet: ConditionSetInput! actionIds: [ID!]! + actionParameters: [RuleActionParameterValuesInput!] policyIds: [ID!]! tags: [String!]! # GraphQL doesn't support BIGINT, so this must be a Float @@ -339,6 +360,7 @@ const typeDefs = /* GraphQL */ ` status: RuleStatus conditionSet: ConditionSetInput actionIds: [ID!] + actionParameters: [RuleActionParameterValuesInput!] policyIds: [ID!] tags: [String!] # GraphQL doesn't support BIGINT, so this must be a Float @@ -634,6 +656,43 @@ const Rule: GQLRuleResolvers = { }, }; +// Resolver shared by ContentRule and UserRule; omits actions with no +// configured values so they don't surface as empty entries. +async function resolveRuleActions( + context: Context, + orgId: string, + ruleId: string, +) { + const withParams = + await context.services.ModerationConfigService.getActionsForRuleId({ + orgId, + ruleId, + }); + return withParams.map((it) => ({ + ...it.action, + configuredParameters: + Object.keys(it.parameters).length > 0 ? it.parameters : null, + })); +} + +async function resolveRuleActionParameters( + context: Context, + orgId: string, + ruleId: string, +): Promise<{ actionId: string; parameters: JsonObject }[]> { + const withParams = + await context.services.ModerationConfigService.getActionsForRuleId({ + orgId, + ruleId, + }); + return withParams + .filter((it) => Object.keys(it.parameters).length > 0) + .map((it) => ({ + actionId: it.action.id, + parameters: it.parameters, + })); +} + const ContentRule: GQLContentRuleResolvers = { async creator(rule, _, context) { const user = context.getUser(); @@ -659,10 +718,14 @@ const ContentRule: GQLContentRuleResolvers = { throw unauthenticatedError('Authenticated user required'); } - return context.services.ModerationConfigService.getActionsForRuleId({ - orgId: user.orgId, - ruleId: rule.id, - }); + return resolveRuleActions(context, user.orgId, rule.id); + }, + async actionParameters(rule, _, context) { + const user = context.getUser(); + if (user == null) { + throw unauthenticatedError('Authenticated user required'); + } + return resolveRuleActionParameters(context, user.orgId, rule.id); }, async policies(rule, _, context) { const user = context.getUser(); @@ -712,10 +775,14 @@ const UserRule: GQLUserRuleResolvers = { throw unauthenticatedError('Authenticated user required'); } - return context.services.ModerationConfigService.getActionsForRuleId({ - orgId: user.orgId, - ruleId: rule.id, - }); + return resolveRuleActions(context, user.orgId, rule.id); + }, + async actionParameters(rule, _, context) { + const user = context.getUser(); + if (user == null) { + throw unauthenticatedError('Authenticated user required'); + } + return resolveRuleActionParameters(context, user.orgId, rule.id); }, async policies(rule, _, context) { const user = context.getUser(); diff --git a/server/rule_engine/RuleEngine.test.ts b/server/rule_engine/RuleEngine.test.ts new file mode 100644 index 0000000..6b4939d --- /dev/null +++ b/server/rule_engine/RuleEngine.test.ts @@ -0,0 +1,66 @@ +import { resolveConfiguredParametersByActionId } from './RuleEngine.js'; + +// Mirrors the serialized spec stored in `actions.custom_mrt_api_params`. +const banDaysSpec = [ + { + name: 'banDays', + displayName: 'Ban days', + type: 'NUMBER', + required: true, + min: 1, + defaultValue: 7, + }, +]; + +function customAction(id: string, customMrtApiParams: unknown) { + return { id, actionType: 'CUSTOM_ACTION', customMrtApiParams }; +} + +describe('resolveConfiguredParametersByActionId', () => { + it('resolves configured values for a parameterized action', () => { + const result = resolveConfiguredParametersByActionId([ + [ + { + action: customAction('a1', banDaysSpec), + parameters: { banDays: 30 }, + }, + ], + ]); + expect(Object.fromEntries(result)).toEqual({ a1: { banDays: 30 } }); + }); + + it('keeps the first non-empty configuration when an action is shared across rules', () => { + const result = resolveConfiguredParametersByActionId([ + // Rule 1 attaches a1 with banDays=30. + [ + { + action: customAction('a1', banDaysSpec), + parameters: { banDays: 30 }, + }, + ], + // Rule 2 attaches the same a1 with banDays=1; deduped, so it's ignored. + [{ action: customAction('a1', banDaysSpec), parameters: { banDays: 1 } }], + ]); + expect(Object.fromEntries(result)).toEqual({ a1: { banDays: 30 } }); + }); + + it('falls back to spec defaults when the configured value is invalid', () => { + const result = resolveConfiguredParametersByActionId([ + [{ action: customAction('a1', banDaysSpec), parameters: { banDays: 0 } }], + ]); + expect(Object.fromEntries(result)).toEqual({ a1: { banDays: 7 } }); + }); + + it('omits actions that declare no parameters', () => { + const result = resolveConfiguredParametersByActionId([ + [ + { action: customAction('a1', null), parameters: {} }, + { + action: { id: 'a2', actionType: 'ENQUEUE_TO_MRT' }, + parameters: { banDays: 30 }, + }, + ], + ]); + expect(result.size).toBe(0); + }); +}); diff --git a/server/rule_engine/RuleEngine.ts b/server/rule_engine/RuleEngine.ts index 755028c..f268d55 100644 --- a/server/rule_engine/RuleEngine.ts +++ b/server/rule_engine/RuleEngine.ts @@ -12,6 +12,7 @@ import { type RuleExecutionCorrelationId } from '../services/analyticsLoggers/in import { type ItemSubmission } from '../services/itemProcessingService/index.js'; import { ConditionCompletionOutcome, + resolveConfiguredActionParameterValues, RuleStatus, type Action, type ConditionSet, @@ -52,6 +53,46 @@ export enum RuleEnvironment { RETROACTION = 'RETROACTION', } +/** + * Resolves the configured parameter values to send for each action triggered by + * a set of rules, keyed by action id. Actions are deduped across rules (we only + * publish each action once), so when several rules attach the same action the + * first non-empty configuration wins. Actions that resolve to no values are + * omitted so the publisher can skip the payload entirely. + */ +export function resolveConfiguredParametersByActionId( + actionsWithParametersByRule: Iterable< + readonly { + action: { id: string; actionType: string; customMrtApiParams?: unknown }; + parameters: unknown; + }[] + >, +): Map> { + const configuredParametersByActionId = new Map< + string, + Record + >(); + for (const actionsWithParameters of actionsWithParametersByRule) { + for (const { action, parameters } of actionsWithParameters) { + if (configuredParametersByActionId.has(action.id)) { + continue; + } + const resolved = resolveConfiguredActionParameterValues({ + customMrtApiParams: + action.actionType === 'CUSTOM_ACTION' + ? action.customMrtApiParams + : null, + rawValues: parameters, + actionId: action.id, + }); + if (resolved !== undefined && Object.keys(resolved).length > 0) { + configuredParametersByActionId.set(action.id, resolved); + } + } + } + return configuredParametersByActionId; +} + /** * This is the main Rule Engine class. It's responsible for running * all of the user's Rules on a single piece of content sent to @@ -223,19 +264,36 @@ class RuleEngine { const actionableRules = passingRules; - const actionableRulesToActions = new Map( + const actionableRulesToActionsWithParameters = new Map( await Promise.all( actionableRules.map(async (rule) => { - const actions = (await this.getRuleActionsEventuallyConsistent({ - orgId: evaluationContext.org.id, - ruleId: rule.id, - })) satisfies readonly ReadonlyDeep[] as readonly Action[]; - - return [rule, actions] as const; + const actionsWithParameters = + await this.getRuleActionsEventuallyConsistent({ + orgId: evaluationContext.org.id, + ruleId: rule.id, + }); + return [rule, actionsWithParameters] as const; }), ), ); + const actionableRulesToActions = new Map( + [...actionableRulesToActionsWithParameters.entries()].map( + ([rule, actionsWithParameters]) => + [ + rule, + actionsWithParameters.map( + (it) => it.action, + ) satisfies readonly ReadonlyDeep[] as readonly Action[], + ] as const, + ), + ); + + const configuredParametersByActionId = + resolveConfiguredParametersByActionId( + actionableRulesToActionsWithParameters.values(), + ); + // NB: while we only run _deduped_ actions, we record the actions and // update the rule action run counts as though no deduping took place, // since, logically, each rule triggered the action. @@ -291,6 +349,8 @@ class RuleEngine { return { action, ruleEnvironment: environment, + customMrtApiParamDecisionPayload: + configuredParametersByActionId.get(action.id), matchingRules: [...actionableRulesToActions.entries()].flatMap( ([rule, actions]) => actions.includes(action) diff --git a/server/services/moderationConfigService/dbTypes.ts b/server/services/moderationConfigService/dbTypes.ts index 600fe07..a1408f7 100644 --- a/server/services/moderationConfigService/dbTypes.ts +++ b/server/services/moderationConfigService/dbTypes.ts @@ -67,6 +67,8 @@ export type ModerationConfigServicePg = { 'public.rules_and_actions': { action_id: string; rule_id: string; + // Configured `name -> value` parameters sent when this rule fires the action. + action_parameters: Generated; created_at: GeneratedAlways; updated_at: GeneratedAlways; sys_period: GeneratedAlways; @@ -162,6 +164,8 @@ export type ModerationConfigServicePg = { org_id: string; threshold: number; actions: string[]; + // Per-action configured parameters: `action_id -> { name -> value }`. + action_parameters: Generated; }; 'public.text_banks': { id: string; diff --git a/server/services/moderationConfigService/index.ts b/server/services/moderationConfigService/index.ts index 6bb7995..4069533 100644 --- a/server/services/moderationConfigService/index.ts +++ b/server/services/moderationConfigService/index.ts @@ -94,7 +94,10 @@ export { parseStoredParameters, validateActionParameters, } from './modules/actionParametersValidation.js'; -export { validateActionParameterValues } from './modules/actionParameterValueValidation.js'; +export { + resolveConfiguredActionParameterValues, + validateActionParameterValues, +} from './modules/actionParameterValueValidation.js'; export { MAX_ACTOR_NOTE_LENGTH, validateActorNote, diff --git a/server/services/moderationConfigService/moderationConfigService.test.ts b/server/services/moderationConfigService/moderationConfigService.test.ts index 212e3fa..fef4819 100644 --- a/server/services/moderationConfigService/moderationConfigService.test.ts +++ b/server/services/moderationConfigService/moderationConfigService.test.ts @@ -1396,7 +1396,7 @@ describe('ModerationConfigService', () => { ruleId: rule.id, readFromReplica: false, }); - expect(result.map((it) => it.id)).toEqual([action.id]); + expect(result.map((it) => it.action.id)).toEqual([action.id]); }, ); diff --git a/server/services/moderationConfigService/moderationConfigService.ts b/server/services/moderationConfigService/moderationConfigService.ts index 3167fd1..e099af3 100644 --- a/server/services/moderationConfigService/moderationConfigService.ts +++ b/server/services/moderationConfigService/moderationConfigService.ts @@ -1,6 +1,6 @@ import { type Kysely } from 'kysely'; import _ from 'lodash'; -import { type ReadonlyDeep } from 'type-fest'; +import { type JsonObject, type ReadonlyDeep } from 'type-fest'; import { type ConsumerDirectives } from '../../lib/cache/index.js'; import type { Invoker } from '../userManagementService/index.js'; @@ -397,6 +397,7 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { thresholdSettings: { threshold: number; actions: string[]; + actionParameters?: JsonObject; }; }) { return this.userStrikeOps.createUserStrikeThreshold(opts); @@ -407,6 +408,7 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { thresholds: readonly { threshold: number; actions: readonly string[]; + actionParameters?: JsonObject; }[]; }) { return this.userStrikeOps.setAllUserStrikeThresholds(opts); @@ -414,7 +416,12 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { async updateUserStrikeThreshold(opts: { orgId: string; - thresholdSettings: { id: string; threshold?: number; actions?: string[] }; + thresholdSettings: { + id: string; + threshold?: number; + actions?: string[]; + actionParameters?: JsonObject; + }; }) { return this.userStrikeOps.updateUserStrikeThreshold(opts); } diff --git a/server/services/moderationConfigService/modules/ActionOperations.ts b/server/services/moderationConfigService/modules/ActionOperations.ts index 49e56d5..a961d61 100644 --- a/server/services/moderationConfigService/modules/ActionOperations.ts +++ b/server/services/moderationConfigService/modules/ActionOperations.ts @@ -418,22 +418,27 @@ export default class ActionOperations { return results.map((it) => this.#dbResultToAction(it)); } + // Returns each action attached to a rule paired with the parameter values + // configured for it. Powers proactive rule execution and pre-fills the rule editor. async getActionsForRuleId(opts: { orgId: string; ruleId: string; readFromReplica?: boolean; - }) { + }): Promise { const { orgId, ruleId, readFromReplica } = opts; - const pgQuery = this.#getPgQuery(readFromReplica); - const results = (await pgQuery + const results = (await this.#getPgQuery(readFromReplica) .selectFrom('public.rules_and_actions as raa') .innerJoin('public.actions as a', 'a.id', 'raa.action_id') - .select(actionJoinDbSelection) + .select([...actionJoinDbSelection, 'raa.action_parameters']) .where('raa.rule_id', '=', ruleId) .where('a.org_id', '=', orgId) - .execute()) as ActionDbResult[]; - - return results.map((it) => this.#dbResultToAction(it)); + .execute()) as (ActionDbResult & { + action_parameters: JsonObject | null; + })[]; + return results.map((it) => ({ + action: this.#dbResultToAction(it), + parameters: it.action_parameters ?? {}, + })); } private static customMrtApiParamsFromDb( diff --git a/server/services/moderationConfigService/modules/UserStrikeOperations.ts b/server/services/moderationConfigService/modules/UserStrikeOperations.ts index c89ca29..ef76a7a 100644 --- a/server/services/moderationConfigService/modules/UserStrikeOperations.ts +++ b/server/services/moderationConfigService/modules/UserStrikeOperations.ts @@ -1,4 +1,5 @@ import { type Kysely } from 'kysely'; +import { type JsonObject } from 'type-fest'; import { CoopError, @@ -18,6 +19,7 @@ const userStrikeThresholdSelection = [ 'org_id as orgId', 'threshold', 'actions', + 'action_parameters as actionParameters', ] as const; export default class UserStrikeOperations { @@ -48,6 +50,7 @@ export default class UserStrikeOperations { thresholdSettings: { threshold: number; actions: string[]; + actionParameters?: JsonObject; }; }) { const { orgId: org_id, thresholdSettings } = opts; @@ -59,6 +62,7 @@ export default class UserStrikeOperations { org_id, threshold: thresholdSettings.threshold, actions: thresholdSettings.actions, + action_parameters: thresholdSettings.actionParameters ?? {}, }) .returning(userStrikeThresholdSelection) .executeTakeFirstOrThrow(); @@ -75,6 +79,7 @@ export default class UserStrikeOperations { id: string; threshold?: number; actions?: string[]; + actionParameters?: JsonObject; }; }) { const { orgId, thresholdSettings } = opts; @@ -86,6 +91,7 @@ export default class UserStrikeOperations { removeUndefinedKeys({ threshold: thresholdSettings.threshold, actions: thresholdSettings.actions, + action_parameters: thresholdSettings.actionParameters, }), ) .where('id', '=', thresholdSettings.id) @@ -107,6 +113,7 @@ export default class UserStrikeOperations { thresholds: readonly { threshold: number; actions: readonly string[]; + actionParameters?: JsonObject; }[]; }) { await this.transactionWithRetry(async (trx) => { @@ -121,10 +128,12 @@ export default class UserStrikeOperations { org_id: opts.orgId, threshold: threshold.threshold, actions: [...threshold.actions], + action_parameters: threshold.actionParameters ?? {}, }) .onConflict((oc) => oc.columns(['org_id', 'threshold']).doUpdateSet({ actions: [...threshold.actions], + action_parameters: threshold.actionParameters ?? {}, }), ) .execute(); diff --git a/server/services/moderationConfigService/modules/actionParameterValueValidation.test.ts b/server/services/moderationConfigService/modules/actionParameterValueValidation.test.ts new file mode 100644 index 0000000..7f258fc --- /dev/null +++ b/server/services/moderationConfigService/modules/actionParameterValueValidation.test.ts @@ -0,0 +1,80 @@ +import { resolveConfiguredActionParameterValues } from './actionParameterValueValidation.js'; + +describe('resolveConfiguredActionParameterValues', () => { + // Mirrors the serialized shape stored in `actions.custom_mrt_api_params`. + const banDaysSpec = [ + { + name: 'banDays', + displayName: 'Ban days', + type: 'NUMBER', + required: true, + min: 1, + defaultValue: 7, + }, + ]; + + it('returns undefined when the action declares no parameters', () => { + expect( + resolveConfiguredActionParameterValues({ + customMrtApiParams: null, + rawValues: { banDays: 3 }, + actionId: 'a1', + }), + ).toBeUndefined(); + expect( + resolveConfiguredActionParameterValues({ + customMrtApiParams: [], + rawValues: undefined, + actionId: 'a1', + }), + ).toBeUndefined(); + }); + + it('returns the validated values for a valid configuration', () => { + expect( + resolveConfiguredActionParameterValues({ + customMrtApiParams: banDaysSpec, + rawValues: { banDays: 30 }, + actionId: 'a1', + }), + ).toEqual({ banDays: 30 }); + }); + + it('falls back to spec defaults when the configuration is invalid', () => { + expect( + resolveConfiguredActionParameterValues({ + customMrtApiParams: banDaysSpec, + rawValues: { banDays: 0 }, + actionId: 'a1', + }), + ).toEqual({ banDays: 7 }); + }); + + it('applies defaults when no values are configured', () => { + expect( + resolveConfiguredActionParameterValues({ + customMrtApiParams: banDaysSpec, + rawValues: undefined, + actionId: 'a1', + }), + ).toEqual({ banDays: 7 }); + }); + + it('returns {} when invalid and no default can satisfy the spec', () => { + const requiredNoDefault = [ + { + name: 'reason', + displayName: 'Reason', + type: 'STRING', + required: true, + }, + ]; + expect( + resolveConfiguredActionParameterValues({ + customMrtApiParams: requiredNoDefault, + rawValues: {}, + actionId: 'a1', + }), + ).toEqual({}); + }); +}); diff --git a/server/services/moderationConfigService/modules/actionParameterValueValidation.ts b/server/services/moderationConfigService/modules/actionParameterValueValidation.ts index dc41639..a2bb92c 100644 --- a/server/services/moderationConfigService/modules/actionParameterValueValidation.ts +++ b/server/services/moderationConfigService/modules/actionParameterValueValidation.ts @@ -1,6 +1,10 @@ import { makeBadRequestError } from '../../../utils/errors.js'; +import { logErrorJson } from '../../../utils/logging.js'; import { assertUnreachable } from '../../../utils/misc.js'; -import { type ActionParameter } from './actionParametersValidation.js'; +import { + parseStoredParameters, + type ActionParameter, +} from './actionParametersValidation.js'; function makeInvalidParameterValueError(detail: string) { return makeBadRequestError('Invalid action parameter value', { @@ -74,6 +78,41 @@ export function validateActionParameterValues( return out; } +/** + * Resolve parameter values for an automated (moderator-less) path — proactive + * rules and user-strike thresholds — where values were configured up front. + * + * Unlike {@link validateActionParameterValues}, this never throws: a rule + * firing on incoming content can't surface an error to a human, and throwing + * would abort the item's whole action-publishing. On a validation failure it + * logs and falls back to the spec's `defaultValue`s (`{}` if those fail too). + * Returns `undefined` when the action declares no parameters. + */ +export function resolveConfiguredActionParameterValues(opts: { + customMrtApiParams: unknown; + rawValues: unknown; + actionId: string; +}): Record | undefined { + const spec = parseStoredParameters(opts.customMrtApiParams); + if (spec.length === 0) { + return undefined; + } + try { + return validateActionParameterValues(spec, opts.rawValues); + } catch (error) { + // eslint-disable-next-line no-restricted-syntax + logErrorJson({ + message: `Configured action parameter values failed validation; falling back to defaults actionId=${opts.actionId}`, + error, + }); + try { + return validateActionParameterValues(spec, null); + } catch { + return {}; + } + } +} + // Treats null/undefined as missing for any type. Additionally treats // whitespace-only strings as missing for STRING/SELECT, and `[]` as missing // for MULTISELECT — these would otherwise pass the required check while diff --git a/server/services/userStrikeService/userStrikeService.ts b/server/services/userStrikeService/userStrikeService.ts index 92eacc8..5307abd 100644 --- a/server/services/userStrikeService/userStrikeService.ts +++ b/server/services/userStrikeService/userStrikeService.ts @@ -18,6 +18,7 @@ import { filterNullOrUndefined } from '../../utils/collections.js'; import { toCorrelationId } from '../../utils/correlationIds.js'; import { type ActionExecutionCorrelationId } from '../analyticsLoggers/ActionExecutionLogger.js'; import { + resolveConfiguredActionParameterValues, type Action, type ModerationConfigService, } from '../moderationConfigService/index.js'; @@ -150,7 +151,12 @@ export class UserStrikeService { (it) => currentUserStrikes + currentStrikesToApply >= it.threshold, ) // sort by threshold in descending order - .sort((a, b) => b.threshold - a.threshold)[0]; + .sort((a, b) => b.threshold - a.threshold) + .at(0); + + if (thresholdRuleToApply == null) { + return; + } // construst the actions to publish const actionsToPublish = await this.moderationConfigService.getActions({ @@ -158,6 +164,9 @@ export class UserStrikeService { ids: thresholdRuleToApply.actions, readFromReplica: true, }); + // Per-action values configured on the threshold, so a crossed threshold + // fires parameterized actions with the same inputs a moderator would give. + const configuredParameters = thresholdRuleToApply.actionParameters; const actionExecutionDataArray = actionsToPublish.map((action) => ({ action, orgId: executionContext.orgId, @@ -165,6 +174,14 @@ export class UserStrikeService { policies: [], matchingRules: undefined, ruleEnvironment: undefined, + customMrtApiParamDecisionPayload: resolveConfiguredActionParameterValues({ + customMrtApiParams: + action.actionType === 'CUSTOM_ACTION' + ? action.customMrtApiParams + : null, + rawValues: configuredParameters[action.id], + actionId: action.id, + }), })); await this.publishActions(actionExecutionDataArray, { orgId: executionContext.orgId, -- 2.51.2