Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add parameterized actions to provide runtime values when executing (#400)

* Add typed Action parameter authoring (PR 1 for #377)

Introduce a typed parameter spec for Actions so moderators can be prompted for runtime values when executing an action (e.g. "Ban for N days"). This PR is the authoring and configuration part definition, validation, persistence, and the create/edit UI from the actions form only.

There will be a follow up prs to surface on the actions page and execution as well as handling of audit log.

Added new Actions model and added new fields to the existing actions model.

* Regenerate GraphQL types

Sync the committed `generated.ts` files with the schema after the
docstring trim that landed in 590c506 was applied without re-running
codegen. Fixes the `check_generated_graphql` CI job on PR #400.

* fix tests

* [377] Wire Action parameters through execution + persist to ClickHouse audit log (#408)

* Wire Action parameters through execution + persist to ClickHouse audit log (PR 2 for #377)

Builds on PR 1 (typed parameter authoring, #400) by routing moderator-supplied parameter values from each run UI through the publisher into the action webhook payload, adding a separate "moderator note" field on every execution path, and recording both to the `analytics.ACTION_EXECUTIONS` audit log.

Added validation for the parameters on the action page and the actions form. Added form on the actioning with modal to add details on action to be taken and ability to edit those details.

* Fix ClickHouse action_executions migration syntax

ClickHouse columns are non-nullable by default; the `NOT NULL` keyword
isn't valid in `ADD COLUMN` and was rejected by the migrator. Drop it
and rely on the explicit `DEFAULT '{}'` for back-compat.

* Gate parameterized actions behind the modal in MRT v2 + Investigation

Two paths were previously skipping the parameter prompt and silently
publishing actions with empty values:

1. MRT v2 related-action buttons (e.g. Associated User panel, thread
related actions, content view related actions) called the enqueue
callback as soon as a policy was picked, with no chance to collect
parameter values.

2. The Investigation tool's `ItemAction` rendered all parameter inputs
inline below the action selector, which got visually overwhelming
for actions with several fields and didn't match the modal-based
pattern used elsewhere.

Fix:

- New `useEnqueueActionGate` hook wraps `onEnqueueActions` at the top
of `ManualReviewJobReviewImpl`. Items whose action declares
`parameters` are queued and routed through `ActionParametersModal`
one action at a time; multiple targets enqueued for the same action
in a single batch share one prompt. Items without parameters (or
that already carry a `customMrtApiParamDecisionPayload` from another
flow) pass straight through.
- `ItemAction.tsx` now opens `ActionParametersModal` immediately when
a parameterized action is selected and shows a small "Edit"
affordance for re-opening it. Cancelling the modal removes the
staged action so required values can never be skipped.

* Add Edit affordance for parameterized actions in Other Actions panel

The Other Actions panel had no way to revise parameter values once an
action was enqueued — moderators had to remove the action and re-add it
just to fix a typo. Add a small pencil button next to each entry that
declares parameters; clicking it reopens `ActionParametersModal` in
edit mode, pre-filled with the entry's current payload, and only
updates that one entry on save.

The edit path is exposed via a new `editParameters` method on the
shared `useEnqueueActionGate` hook so the create and edit flows reuse
the same modal queue and only one modal is ever rendered at a time.

authored by

Juan Mrad and committed by
GitHub
065bc7a6 93e30cc5

+4869 -517
+297
client/src/components/ActionParameterInputs.tsx
··· 1 + import { InfoCircleOutlined } from '@ant-design/icons'; 2 + import { Input, InputNumber, Select, Switch, Tooltip } from 'antd'; 3 + import { useMemo } from 'react'; 4 + 5 + import { 6 + type GQLActionParameter, 7 + GQLActionParameterType, 8 + } from '../graphql/generated'; 9 + 10 + const { Option } = Select; 11 + 12 + export type ActionParameterValues = Readonly<Record<string, unknown>>; 13 + 14 + type Props = { 15 + parameters: ReadonlyArray<GQLActionParameter>; 16 + values: ActionParameterValues; 17 + onChange: (next: ActionParameterValues) => void; 18 + /** Optional id prefix so multiple instances on a page get unique input ids. */ 19 + idPrefix?: string; 20 + disabled?: boolean; 21 + }; 22 + 23 + /** 24 + * Renders one input widget per `ActionParameter`, using the appropriate 25 + * Ant Design control for each parameter `type`. Designed as the single source 26 + * of truth for moderator-facing parameter entry across the dashboard 27 + * (ItemAction modal, BulkActioningDashboard, MRT review). 28 + * 29 + * The component is fully controlled: the parent owns the `values` map and 30 + * applies the supplied `onChange` callback to integrate edits. 31 + */ 32 + export default function ActionParameterInputs({ 33 + parameters, 34 + values, 35 + onChange, 36 + idPrefix, 37 + disabled, 38 + }: Props) { 39 + const setValue = (name: string, value: unknown) => { 40 + if (value === undefined) { 41 + // Drop the key via destructuring rather than `delete` to satisfy the 42 + // `no-dynamic-delete` rule, and avoid mutating the prop. 43 + const { [name]: _omitted, ...rest } = values; 44 + onChange(rest); 45 + return; 46 + } 47 + onChange({ ...values, [name]: value }); 48 + }; 49 + 50 + if (parameters.length === 0) return null; 51 + 52 + return ( 53 + <div className="flex flex-col gap-4"> 54 + {parameters.map((param) => ( 55 + <ParameterInput 56 + key={param.name} 57 + param={param} 58 + value={values[param.name]} 59 + idPrefix={idPrefix} 60 + disabled={disabled} 61 + onChange={(next) => setValue(param.name, next)} 62 + /> 63 + ))} 64 + </div> 65 + ); 66 + } 67 + 68 + function ParameterInput({ 69 + param, 70 + value, 71 + onChange, 72 + idPrefix, 73 + disabled, 74 + }: { 75 + param: GQLActionParameter; 76 + value: unknown; 77 + onChange: (next: unknown) => void; 78 + idPrefix?: string; 79 + disabled?: boolean; 80 + }) { 81 + const id = `${idPrefix ?? 'param'}-${param.name}`; 82 + const constraints = constraintHint(param); 83 + const labelTooltip = [param.description, constraints] 84 + .filter((s): s is string => Boolean(s)) 85 + .join(' — '); 86 + 87 + const label = ( 88 + <label 89 + htmlFor={id} 90 + className="mb-1 text-sm font-medium text-gray-700 inline-flex items-center" 91 + > 92 + {param.displayName} 93 + {param.required && <span className="ml-1 text-coop-alert-red">*</span>} 94 + {labelTooltip && ( 95 + <Tooltip title={labelTooltip}> 96 + <InfoCircleOutlined className="ml-1 text-gray-400" /> 97 + </Tooltip> 98 + )} 99 + </label> 100 + ); 101 + const description = param.description ? ( 102 + <div className="mb-1 text-xs text-gray-500">{param.description}</div> 103 + ) : null; 104 + const constraintHintBelow = constraints ? ( 105 + <div className="mt-1 text-xs text-gray-400">{constraints}</div> 106 + ) : null; 107 + 108 + const inputElement = (() => { 109 + switch (param.type) { 110 + case GQLActionParameterType.String: 111 + return ( 112 + <Input 113 + id={id} 114 + disabled={disabled} 115 + maxLength={param.maxLength ?? undefined} 116 + // `showCount` adds the live "n / max" indicator when a maxLength 117 + // is declared so moderators see how close they are to the limit. 118 + showCount={param.maxLength != null} 119 + value={typeof value === 'string' ? value : ''} 120 + onChange={(e) => 121 + onChange(e.target.value === '' ? undefined : e.target.value) 122 + } 123 + /> 124 + ); 125 + case GQLActionParameterType.Number: 126 + return ( 127 + <InputNumber 128 + id={id} 129 + disabled={disabled} 130 + min={param.min ?? undefined} 131 + max={param.max ?? undefined} 132 + value={typeof value === 'number' ? value : undefined} 133 + onChange={(next) => onChange(next ?? undefined)} 134 + style={{ width: '100%' }} 135 + /> 136 + ); 137 + case GQLActionParameterType.Boolean: 138 + // Wrap in a `self-start`/`inline-flex` span so the parent 139 + // `flex flex-col` doesn't stretch the Switch button to full width. 140 + return ( 141 + <span className="self-start inline-flex"> 142 + <Switch 143 + id={id} 144 + disabled={disabled} 145 + checked={value === true} 146 + onChange={(checked) => onChange(checked)} 147 + /> 148 + </span> 149 + ); 150 + case GQLActionParameterType.Select: { 151 + const options = param.options ?? []; 152 + return ( 153 + <Select 154 + id={id} 155 + disabled={disabled} 156 + style={{ width: '100%' }} 157 + value={typeof value === 'string' ? value : undefined} 158 + onChange={(next) => onChange(next ?? undefined)} 159 + allowClear 160 + > 161 + {options.map((opt) => ( 162 + <Option key={opt.value} value={opt.value}> 163 + {opt.label} 164 + </Option> 165 + ))} 166 + </Select> 167 + ); 168 + } 169 + case GQLActionParameterType.Multiselect: { 170 + const options = param.options ?? []; 171 + return ( 172 + <Select<string[]> 173 + id={id} 174 + disabled={disabled} 175 + mode="multiple" 176 + style={{ width: '100%' }} 177 + value={ 178 + Array.isArray(value) 179 + ? value.filter((v): v is string => typeof v === 'string') 180 + : [] 181 + } 182 + onChange={(next) => onChange(next.length === 0 ? undefined : next)} 183 + allowClear 184 + > 185 + {options.map((opt) => ( 186 + <Option key={opt.value} value={opt.value}> 187 + {opt.label} 188 + </Option> 189 + ))} 190 + </Select> 191 + ); 192 + } 193 + default: 194 + return null; 195 + } 196 + })(); 197 + 198 + return ( 199 + <div className="flex flex-col"> 200 + {label} 201 + {description} 202 + {inputElement} 203 + {constraintHintBelow} 204 + </div> 205 + ); 206 + } 207 + 208 + /** 209 + * Renders a short human-readable summary of the validation constraints on a 210 + * parameter, e.g. "Between 1 and 365" for a NUMBER with min/max, or 211 + * "Up to 500 characters" for a STRING with maxLength. Returns `undefined` 212 + * when there's nothing useful to surface (e.g. BOOLEAN, or unconstrained 213 + * STRING). Also used as the label tooltip body alongside the description. 214 + */ 215 + function constraintHint(param: GQLActionParameter): string | undefined { 216 + switch (param.type) { 217 + case GQLActionParameterType.String: { 218 + if (param.maxLength != null) { 219 + return `Up to ${param.maxLength} characters`; 220 + } 221 + return undefined; 222 + } 223 + case GQLActionParameterType.Number: { 224 + const { min, max } = param; 225 + if (min != null && max != null) return `Between ${min} and ${max}`; 226 + if (min != null) return `At least ${min}`; 227 + if (max != null) return `At most ${max}`; 228 + return undefined; 229 + } 230 + case GQLActionParameterType.Select: 231 + return 'Choose one'; 232 + case GQLActionParameterType.Multiselect: 233 + return 'Choose one or more'; 234 + case GQLActionParameterType.Boolean: 235 + default: 236 + return undefined; 237 + } 238 + } 239 + 240 + /** 241 + * Returns a list of parameter `displayName`s that are required but missing or 242 + * empty in `values`. Empty list means the values map is submittable. Used by 243 + * callers to gate the submit button and surface the missing fields in a 244 + * disabled-button tooltip. 245 + */ 246 + export function findMissingRequiredParameters( 247 + parameters: ReadonlyArray<GQLActionParameter>, 248 + values: ActionParameterValues, 249 + ): string[] { 250 + const missing: string[] = []; 251 + for (const param of parameters) { 252 + if (!param.required) continue; 253 + const present = Object.prototype.hasOwnProperty.call(values, param.name); 254 + const value = values[param.name]; 255 + const isEmpty = 256 + !present || 257 + value === undefined || 258 + value === null || 259 + value === '' || 260 + (Array.isArray(value) && value.length === 0); 261 + // A `defaultValue` on the spec satisfies "required" since the server will 262 + // backfill on publish — keeps the UX consistent with server behavior. 263 + if (isEmpty && (param.defaultValue === undefined || param.defaultValue === null)) { 264 + missing.push(param.displayName); 265 + } 266 + } 267 + return missing; 268 + } 269 + 270 + /** 271 + * Memoized helper: takes a `Record<actionId, ActionParameterValues>` map and a 272 + * single action-id update, returns the next map with that one action's values 273 + * replaced. Tiny but used in three call sites. 274 + */ 275 + export function useUpdateActionValues( 276 + setMap: ( 277 + next: Readonly<Record<string, ActionParameterValues>>, 278 + ) => void, 279 + map: Readonly<Record<string, ActionParameterValues>>, 280 + ) { 281 + return useMemo( 282 + () => 283 + (actionId: string, values: ActionParameterValues) => { 284 + // Drop the entry entirely when the values map is empty, so the GQL 285 + // input doesn't carry meaningless `{}` entries that confuse log 286 + // readers. 287 + if (Object.keys(values).length === 0) { 288 + if (!(actionId in map)) return; 289 + const { [actionId]: _omitted, ...rest } = map; 290 + setMap(rest); 291 + return; 292 + } 293 + setMap({ ...map, [actionId]: values }); 294 + }, 295 + [map, setMap], 296 + ); 297 + }
+115
client/src/components/ActionParametersModal.tsx
··· 1 + import { Tooltip } from 'antd'; 2 + import { useEffect, useState } from 'react'; 3 + 4 + import CoopModal from '@/webpages/dashboard/components/CoopModal'; 5 + import { type CoopModalFooterButtonProps } from '@/webpages/dashboard/components/CoopModalFooter'; 6 + import { type GQLActionParameter } from '@/graphql/generated'; 7 + 8 + import ActionParameterInputs, { 9 + type ActionParameterValues, 10 + findMissingRequiredParameters, 11 + } from './ActionParameterInputs'; 12 + 13 + type Props = { 14 + open: boolean; 15 + /** Display name shown in the modal title (e.g. action name). */ 16 + actionName: string; 17 + parameters: ReadonlyArray<GQLActionParameter>; 18 + /** 19 + * Values to pre-fill the form with. In `create` mode this is typically the 20 + * spec's defaults; in `edit` mode it's whatever the moderator previously 21 + * saved. The modal owns its own working copy and only reports it back via 22 + * `onSave`, so cancelling never mutates the parent. 23 + */ 24 + initialValues: ActionParameterValues; 25 + /** `create` adds a new selection; `edit` updates an existing one. */ 26 + mode: 'create' | 'edit'; 27 + onSave: (values: ActionParameterValues) => void; 28 + onCancel: () => void; 29 + }; 30 + 31 + /** 32 + * Modal wrapper around `ActionParameterInputs` for picking parameter values 33 + * before an action is committed to the selection. Used in screens where 34 + * multiple parameterized actions can be selected and inline editing would 35 + * become visually crowded (e.g. MRT review). 36 + */ 37 + export default function ActionParametersModal({ 38 + open, 39 + actionName, 40 + parameters, 41 + initialValues, 42 + mode, 43 + onSave, 44 + onCancel, 45 + }: Props) { 46 + const [values, setValues] = useState<ActionParameterValues>(initialValues); 47 + 48 + // Re-seed the working copy whenever the modal is (re-)opened so a stale 49 + // edit from a previous open doesn't leak into a new session. 50 + useEffect(() => { 51 + if (open) { 52 + setValues(initialValues); 53 + } 54 + }, [open, initialValues]); 55 + 56 + const missing = findMissingRequiredParameters(parameters, values); 57 + const canSave = missing.length === 0; 58 + const saveTitle = mode === 'create' ? 'Add' : 'Save'; 59 + 60 + const footer: CoopModalFooterButtonProps[] = [ 61 + { 62 + title: 'Cancel', 63 + type: 'secondary', 64 + onClick: onCancel, 65 + }, 66 + { 67 + title: saveTitle, 68 + type: 'primary', 69 + disabled: !canSave, 70 + onClick: () => onSave(values), 71 + }, 72 + ]; 73 + 74 + return ( 75 + <CoopModal 76 + visible={open} 77 + onClose={onCancel} 78 + title={`"${actionName}" details`} 79 + footer={footer} 80 + > 81 + <div className="flex flex-col min-w-[28rem] max-w-[36rem]"> 82 + <ActionParameterInputs 83 + parameters={parameters} 84 + values={values} 85 + onChange={setValues} 86 + idPrefix={`action-params-modal-${actionName}`} 87 + /> 88 + {!canSave && ( 89 + <Tooltip title={`Missing: ${missing.join(', ')}`}> 90 + <div className="mt-3 text-xs text-coop-alert-red"> 91 + Fill in {missing.length === 1 ? 'the required field' : 'all required fields'} to continue. 92 + </div> 93 + </Tooltip> 94 + )} 95 + </div> 96 + </CoopModal> 97 + ); 98 + } 99 + 100 + /** 101 + * Builds an initial `ActionParameterValues` map from a parameter spec by 102 + * copying each parameter's `defaultValue` (when set). Used to seed the modal 103 + * the first time an action is selected. 104 + */ 105 + export function defaultValuesForParameters( 106 + parameters: ReadonlyArray<GQLActionParameter>, 107 + ): ActionParameterValues { 108 + const out: Record<string, unknown> = {}; 109 + for (const param of parameters) { 110 + if (param.defaultValue !== undefined && param.defaultValue !== null) { 111 + out[param.name] = param.defaultValue; 112 + } 113 + } 114 + return out; 115 + }
+175 -5
client/src/components/ItemAction.tsx
··· 1 + import ActionParametersModal, { 2 + defaultValuesForParameters, 3 + } from '@/components/ActionParametersModal'; 4 + import { type ActionParameterValues } from '@/components/ActionParameterInputs'; 5 + import { type JsonObject } from 'type-fest'; 1 6 import { 7 + type GQLActionParameter, 2 8 namedOperations, 3 9 useGQLBulkActionExecutionMutation, 4 10 useGQLBulkActionsFormDataQuery, 5 11 } from '@/graphql/generated'; 6 12 import { stripTypename } from '@/graphql/inputHelpers'; 7 13 import { ItemIdentifier } from '@roostorg/types'; 8 - import { Select } from 'antd'; 14 + import Pencil from '@/icons/lni/Education/pencil.svg?react'; 15 + import { Button, Input, Select } from 'antd'; 9 16 import orderBy from 'lodash/orderBy'; 10 17 import { useCallback, useMemo, useState } from 'react'; 11 18 ··· 16 23 17 24 const { Option } = Select; 18 25 26 + type EligibleAction = { 27 + id: string; 28 + name: string; 29 + parameters: ReadonlyArray<GQLActionParameter>; 30 + }; 31 + 32 + type ParamsModalState = 33 + | { open: false } 34 + | { open: true; mode: 'create' | 'edit'; actionId: string }; 35 + 19 36 export default function ItemAction(props: { 20 37 itemIdentifier: ItemIdentifier; 21 38 title?: string; ··· 50 67 const [selectedActionIds, setSelectedActionIds] = useState<string[]>([]); 51 68 const [showModal, setShowModal] = useState(false); 52 69 const [modalBody, setModalBody] = useState<string>(''); 70 + const [parametersByActionId, setParametersByActionId] = useState< 71 + Record<string, ActionParameterValues> 72 + >({}); 73 + const [paramsModal, setParamsModal] = useState<ParamsModalState>({ 74 + open: false, 75 + }); 76 + const [moderatorNote, setModeratorNote] = useState<string>(''); 53 77 54 - const eligibleActions = (queryData?.myOrg?.actions ?? []).filter((it) => 55 - it.itemTypes.map((it) => it.id).includes(itemIdentifier.typeId), 78 + const eligibleActions: EligibleAction[] = (queryData?.myOrg?.actions ?? []) 79 + .filter((it) => it.itemTypes.map((t) => t.id).includes(itemIdentifier.typeId)) 80 + .map((it) => ({ 81 + id: it.id, 82 + name: it.name, 83 + parameters: ('parameters' in it ? it.parameters : []) ?? [], 84 + })); 85 + 86 + const eligibleActionsById = useMemo( 87 + () => new Map(eligibleActions.map((a) => [a.id, a])), 88 + [eligibleActions], 89 + ); 90 + 91 + const selectedParameterizedActions = useMemo( 92 + () => 93 + selectedActionIds 94 + .map((id) => eligibleActionsById.get(id)) 95 + .filter( 96 + (a): a is EligibleAction => a != null && a.parameters.length > 0, 97 + ), 98 + [selectedActionIds, eligibleActionsById], 56 99 ); 57 100 58 101 const selectOnChange = useCallback( 59 - (actionIds: string[]) => setSelectedActionIds(actionIds), 60 - [], 102 + (actionIds: string[]) => { 103 + const previous = new Set(selectedActionIds); 104 + const added = actionIds.find((id) => !previous.has(id)); 105 + 106 + const addedAction = added ? eligibleActionsById.get(added) : undefined; 107 + if (addedAction && addedAction.parameters.length > 0) { 108 + // Stage the selection but gate the actual commit behind the modal so 109 + // a moderator can never publish a parameterized action without 110 + // filling in required values. Cancel removes the staged id. 111 + setSelectedActionIds(actionIds); 112 + setParamsModal({ 113 + open: true, 114 + mode: 'create', 115 + actionId: addedAction.id, 116 + }); 117 + return; 118 + } 119 + 120 + setSelectedActionIds(actionIds); 121 + // Drop param payloads for any actions that were just deselected so the 122 + // submitted input doesn't carry stale values. 123 + const next = new Set(actionIds); 124 + setParametersByActionId((prev) => { 125 + const out: Record<string, ActionParameterValues> = {}; 126 + for (const [id, values] of Object.entries(prev)) { 127 + if (next.has(id)) out[id] = values; 128 + } 129 + return out; 130 + }); 131 + }, 132 + [selectedActionIds, eligibleActionsById], 133 + ); 134 + 135 + const onParamsModalCancel = useCallback(() => { 136 + if (paramsModal.open && paramsModal.mode === 'create') { 137 + setSelectedActionIds((ids) => 138 + ids.filter((id) => id !== paramsModal.actionId), 139 + ); 140 + } 141 + setParamsModal({ open: false }); 142 + }, [paramsModal]); 143 + 144 + const onParamsModalSave = useCallback( 145 + (values: ActionParameterValues) => { 146 + if (!paramsModal.open) return; 147 + setParametersByActionId((prev) => ({ 148 + ...prev, 149 + [paramsModal.actionId]: values, 150 + })); 151 + setParamsModal({ open: false }); 152 + }, 153 + [paramsModal], 61 154 ); 62 155 63 156 const selectDropdownRender = useCallback( ··· 107 200 actionIds: selectedActionIds, 108 201 itemIds: [itemIdentifier.id], 109 202 policyIds: selectedPolicyIds, 203 + // Drop empty per-action entries so the input doesn't carry 204 + // meaningless `{}` payloads. GQL `JSONObject` constrains values 205 + // to `JsonValue`; our per-action map's inner values are 206 + // `unknown` because each parameter type produces a different 207 + // concrete value. They're all JSON-serializable in practice 208 + // (string, number, boolean, string[]) and the server 209 + // re-validates. Cast through unknown to satisfy the input type. 210 + parameters: 211 + Object.keys(parametersByActionId).length > 0 212 + ? (parametersByActionId as unknown as JsonObject) 213 + : undefined, 214 + note: moderatorNote.trim() === '' ? undefined : moderatorNote.trim(), 110 215 }, 111 216 }, 112 217 }), ··· 116 221 itemIdentifier.typeId, 117 222 selectedActionIds, 118 223 selectedPolicyIds, 224 + parametersByActionId, 225 + moderatorNote, 119 226 ], 120 227 ); 121 228 ··· 125 232 return null; 126 233 } 127 234 235 + const activeParamsAction = paramsModal.open 236 + ? eligibleActionsById.get(paramsModal.actionId) 237 + : undefined; 238 + 128 239 return ( 129 240 <div className="flex flex-col"> 130 241 <div className="flex flex-col items-start mb-2"> ··· 140 251 placeholder="Select action" 141 252 dropdownMatchSelectWidth={false} 142 253 filterOption={selectFilterByLabelOption} 254 + value={selectedActionIds} 143 255 onChange={selectOnChange} 144 256 dropdownRender={selectDropdownRender} 145 257 > ··· 173 285 disabled={selectedActionIds.length === 0} 174 286 /> 175 287 </div> 288 + {selectedParameterizedActions.length > 0 && ( 289 + <div className="mt-3 flex flex-col gap-1"> 290 + {selectedParameterizedActions.map((action) => ( 291 + <div 292 + key={action.id} 293 + className="flex flex-row items-center gap-2 text-sm" 294 + > 295 + <span className="text-gray-700">{action.name} details:</span> 296 + <Button 297 + type="link" 298 + size="small" 299 + icon={<Pencil className="w-3 h-3" />} 300 + onClick={() => 301 + setParamsModal({ 302 + open: true, 303 + mode: 'edit', 304 + actionId: action.id, 305 + }) 306 + } 307 + > 308 + Edit 309 + </Button> 310 + </div> 311 + ))} 312 + </div> 313 + )} 314 + {selectedActionIds.length > 0 && ( 315 + <div className="mt-4 flex flex-col"> 316 + <label 317 + htmlFor="item-action-moderator-note" 318 + className="mb-1 text-sm font-medium text-gray-700" 319 + > 320 + Note (optional) 321 + </label> 322 + <Input.TextArea 323 + id="item-action-moderator-note" 324 + placeholder="Why are you taking this action? Sent to the action's webhook as `actorNote`." 325 + rows={2} 326 + maxLength={5000} 327 + value={moderatorNote} 328 + onChange={(e) => setModeratorNote(e.target.value)} 329 + /> 330 + </div> 331 + )} 332 + {paramsModal.open && activeParamsAction && ( 333 + <ActionParametersModal 334 + open 335 + mode={paramsModal.mode} 336 + actionName={activeParamsAction.name} 337 + parameters={activeParamsAction.parameters} 338 + initialValues={ 339 + parametersByActionId[activeParamsAction.id] ?? 340 + defaultValuesForParameters(activeParamsAction.parameters) 341 + } 342 + onCancel={onParamsModalCancel} 343 + onSave={onParamsModalSave} 344 + /> 345 + )} 176 346 <CoopModal visible={showModal} onClose={modalOnClose}> 177 347 {modalBody} 178 348 </CoopModal>
+278 -158
client/src/graphql/generated.ts
··· 76 76 readonly itemTypes: ReadonlyArray<GQLItemType>; 77 77 readonly name: Scalars['String']['output']; 78 78 readonly orgId: Scalars['String']['output']; 79 + readonly parameters: ReadonlyArray<GQLActionParameter>; 79 80 readonly penalty: GQLUserPenaltySeverity; 80 81 }; 81 82 ··· 100 101 readonly type: ReadonlyArray<Scalars['String']['output']>; 101 102 }; 102 103 104 + /** 105 + * Definition of a single runtime parameter on an action. The moderator is 106 + * prompted for a value at execution time; the value is included in the 107 + * webhook payload under the parameter's `name`. 108 + */ 109 + export type GQLActionParameter = { 110 + readonly __typename: 'ActionParameter'; 111 + /** Pre-filled value shown to the moderator. Shape matches `type`. */ 112 + readonly defaultValue?: Maybe<Scalars['JSON']['output']>; 113 + readonly description?: Maybe<Scalars['String']['output']>; 114 + readonly displayName: Scalars['String']['output']; 115 + /** NUMBER only: inclusive maximum. */ 116 + readonly max?: Maybe<Scalars['Float']['output']>; 117 + /** STRING only: inclusive maximum length in characters. */ 118 + readonly maxLength?: Maybe<Scalars['Int']['output']>; 119 + /** NUMBER only: inclusive minimum. */ 120 + readonly min?: Maybe<Scalars['Float']['output']>; 121 + /** Key under which the value is sent in the webhook payload. */ 122 + readonly name: Scalars['String']['output']; 123 + readonly options?: Maybe<ReadonlyArray<GQLActionParameterOption>>; 124 + readonly required: Scalars['Boolean']['output']; 125 + readonly type: GQLActionParameterType; 126 + }; 127 + 128 + export type GQLActionParameterInput = { 129 + readonly defaultValue?: InputMaybe<Scalars['JSON']['input']>; 130 + readonly description?: InputMaybe<Scalars['String']['input']>; 131 + readonly displayName: Scalars['String']['input']; 132 + readonly max?: InputMaybe<Scalars['Float']['input']>; 133 + readonly maxLength?: InputMaybe<Scalars['Int']['input']>; 134 + readonly min?: InputMaybe<Scalars['Float']['input']>; 135 + readonly name: Scalars['String']['input']; 136 + readonly options?: InputMaybe<ReadonlyArray<GQLActionParameterOptionInput>>; 137 + readonly required: Scalars['Boolean']['input']; 138 + readonly type: GQLActionParameterType; 139 + }; 140 + 141 + export type GQLActionParameterOption = { 142 + readonly __typename: 'ActionParameterOption'; 143 + readonly label: Scalars['String']['output']; 144 + readonly value: Scalars['String']['output']; 145 + }; 146 + 147 + export type GQLActionParameterOptionInput = { 148 + readonly label: Scalars['String']['input']; 149 + readonly value: Scalars['String']['input']; 150 + }; 151 + 152 + export const GQLActionParameterType = { 153 + Boolean: 'BOOLEAN', 154 + Multiselect: 'MULTISELECT', 155 + Number: 'NUMBER', 156 + Select: 'SELECT', 157 + String: 'STRING', 158 + } as const; 159 + 160 + export type GQLActionParameterType = 161 + (typeof GQLActionParameterType)[keyof typeof GQLActionParameterType]; 103 162 export const GQLActionSource = { 104 163 AutomatedRule: 'AUTOMATED_RULE', 105 164 ManualActionRun: 'MANUAL_ACTION_RUN', ··· 654 713 readonly description?: InputMaybe<Scalars['String']['input']>; 655 714 readonly itemTypeIds: ReadonlyArray<Scalars['ID']['input']>; 656 715 readonly name: Scalars['String']['input']; 716 + readonly parameters?: InputMaybe<ReadonlyArray<GQLActionParameterInput>>; 657 717 }; 658 718 659 719 export type GQLCreateBacktestInput = { ··· 817 877 readonly callbackUrl: Scalars['String']['output']; 818 878 readonly callbackUrlBody?: Maybe<Scalars['JSONObject']['output']>; 819 879 readonly callbackUrlHeaders?: Maybe<Scalars['JSONObject']['output']>; 880 + /** 881 + * Deprecated alias for `parameters` retained for back-compat with the 882 + * initial MRT-only parameter implementation. New consumers should read 883 + * `parameters` instead. 884 + * @deprecated Use `parameters` instead. 885 + */ 820 886 readonly customMrtApiParams: ReadonlyArray<Maybe<GQLCustomMrtApiParamSpec>>; 821 887 readonly description?: Maybe<Scalars['String']['output']>; 822 888 readonly id: Scalars['ID']['output']; 823 889 readonly itemTypes: ReadonlyArray<GQLItemType>; 824 890 readonly name: Scalars['String']['output']; 825 891 readonly orgId: Scalars['String']['output']; 892 + readonly parameters: ReadonlyArray<GQLActionParameter>; 826 893 readonly penalty: GQLUserPenaltySeverity; 827 894 }; 828 895 ··· 1051 1118 readonly itemTypes: ReadonlyArray<GQLItemType>; 1052 1119 readonly name: Scalars['String']['output']; 1053 1120 readonly orgId: Scalars['String']['output']; 1121 + readonly parameters: ReadonlyArray<GQLActionParameter>; 1054 1122 readonly penalty: GQLUserPenaltySeverity; 1055 1123 }; 1056 1124 ··· 1062 1130 readonly itemTypes: ReadonlyArray<GQLItemType>; 1063 1131 readonly name: Scalars['String']['output']; 1064 1132 readonly orgId: Scalars['String']['output']; 1133 + readonly parameters: ReadonlyArray<GQLActionParameter>; 1065 1134 readonly penalty: GQLUserPenaltySeverity; 1066 1135 }; 1067 1136 ··· 1073 1142 readonly itemTypes: ReadonlyArray<GQLItemType>; 1074 1143 readonly name: Scalars['String']['output']; 1075 1144 readonly orgId: Scalars['String']['output']; 1145 + readonly parameters: ReadonlyArray<GQLActionParameter>; 1076 1146 readonly penalty: GQLUserPenaltySeverity; 1077 1147 }; 1078 1148 ··· 1151 1221 readonly actionIds: ReadonlyArray<Scalars['String']['input']>; 1152 1222 readonly itemIds: ReadonlyArray<Scalars['String']['input']>; 1153 1223 readonly itemTypeId: Scalars['String']['input']; 1224 + /** 1225 + * Optional moderator-authored note explaining why this action was taken. 1226 + * Sent to the action's webhook as `actorNote` and persisted to the action 1227 + * execution audit log. 1228 + */ 1229 + readonly note?: InputMaybe<Scalars['String']['input']>; 1230 + /** 1231 + * Optional map of `actionId` -> `{ paramName: value }` carrying 1232 + * moderator-supplied runtime parameter values. Each map is validated against 1233 + * the action's parameter spec server-side before publish; invalid values 1234 + * reject the entire request. 1235 + */ 1236 + readonly parameters?: InputMaybe<Scalars['JSONObject']['input']>; 1154 1237 readonly policyIds: ReadonlyArray<Scalars['String']['input']>; 1155 1238 }; 1156 1239 ··· 4407 4490 readonly id: Scalars['ID']['input']; 4408 4491 readonly itemTypeIds?: InputMaybe<ReadonlyArray<Scalars['ID']['input']>>; 4409 4492 readonly name?: InputMaybe<Scalars['String']['input']>; 4493 + /** Replace the parameter list (`[]` clears it). Omit to leave unchanged. */ 4494 + readonly parameters?: InputMaybe<ReadonlyArray<GQLActionParameterInput>>; 4410 4495 }; 4411 4496 4412 4497 export type GQLUpdateContentItemTypeInput = { ··· 5328 5413 | { readonly __typename: 'ThreadItemType'; readonly id: string } 5329 5414 | { readonly __typename: 'UserItemType'; readonly id: string } 5330 5415 >; 5416 + readonly parameters: ReadonlyArray<{ 5417 + readonly __typename: 'ActionParameter'; 5418 + readonly name: string; 5419 + readonly displayName: string; 5420 + readonly description?: string | null; 5421 + readonly type: GQLActionParameterType; 5422 + readonly required: boolean; 5423 + readonly min?: number | null; 5424 + readonly max?: number | null; 5425 + readonly maxLength?: number | null; 5426 + readonly defaultValue?: JsonValue | null; 5427 + readonly options?: ReadonlyArray<{ 5428 + readonly __typename: 'ActionParameterOption'; 5429 + readonly value: string; 5430 + readonly label: string; 5431 + }> | null; 5432 + }>; 5331 5433 }; 5332 5434 5333 5435 export type GQLActionQueryVariables = Exact<{ ··· 5350 5452 | { readonly __typename: 'ThreadItemType'; readonly id: string } 5351 5453 | { readonly __typename: 'UserItemType'; readonly id: string } 5352 5454 >; 5455 + readonly parameters: ReadonlyArray<{ 5456 + readonly __typename: 'ActionParameter'; 5457 + readonly name: string; 5458 + readonly displayName: string; 5459 + readonly description?: string | null; 5460 + readonly type: GQLActionParameterType; 5461 + readonly required: boolean; 5462 + readonly min?: number | null; 5463 + readonly max?: number | null; 5464 + readonly maxLength?: number | null; 5465 + readonly defaultValue?: JsonValue | null; 5466 + readonly options?: ReadonlyArray<{ 5467 + readonly __typename: 'ActionParameterOption'; 5468 + readonly value: string; 5469 + readonly label: string; 5470 + }> | null; 5471 + }>; 5353 5472 } 5354 5473 | { readonly __typename: 'EnqueueAuthorToMrtAction' } 5355 5474 | { readonly __typename: 'EnqueueToMrtAction' } ··· 5415 5534 | { readonly __typename: 'ThreadItemType'; readonly id: string } 5416 5535 | { readonly __typename: 'UserItemType'; readonly id: string } 5417 5536 >; 5537 + readonly parameters: ReadonlyArray<{ 5538 + readonly __typename: 'ActionParameter'; 5539 + readonly name: string; 5540 + readonly displayName: string; 5541 + readonly description?: string | null; 5542 + readonly type: GQLActionParameterType; 5543 + readonly required: boolean; 5544 + readonly min?: number | null; 5545 + readonly max?: number | null; 5546 + readonly maxLength?: number | null; 5547 + readonly defaultValue?: JsonValue | null; 5548 + readonly options?: ReadonlyArray<{ 5549 + readonly __typename: 'ActionParameterOption'; 5550 + readonly value: string; 5551 + readonly label: string; 5552 + }> | null; 5553 + }>; 5418 5554 }; 5419 5555 }; 5420 5556 }; ··· 5447 5583 | { readonly __typename: 'ThreadItemType'; readonly id: string } 5448 5584 | { readonly __typename: 'UserItemType'; readonly id: string } 5449 5585 >; 5586 + readonly parameters: ReadonlyArray<{ 5587 + readonly __typename: 'ActionParameter'; 5588 + readonly name: string; 5589 + readonly displayName: string; 5590 + readonly description?: string | null; 5591 + readonly type: GQLActionParameterType; 5592 + readonly required: boolean; 5593 + readonly min?: number | null; 5594 + readonly max?: number | null; 5595 + readonly maxLength?: number | null; 5596 + readonly defaultValue?: JsonValue | null; 5597 + readonly options?: ReadonlyArray<{ 5598 + readonly __typename: 'ActionParameterOption'; 5599 + readonly value: string; 5600 + readonly label: string; 5601 + }> | null; 5602 + }>; 5450 5603 }; 5451 5604 }; 5452 5605 }; ··· 5743 5896 readonly __typename: 'CustomAction'; 5744 5897 readonly id: string; 5745 5898 readonly name: string; 5899 + readonly parameters: ReadonlyArray<{ 5900 + readonly __typename: 'ActionParameter'; 5901 + readonly name: string; 5902 + readonly displayName: string; 5903 + readonly description?: string | null; 5904 + readonly type: GQLActionParameterType; 5905 + readonly required: boolean; 5906 + readonly min?: number | null; 5907 + readonly max?: number | null; 5908 + readonly maxLength?: number | null; 5909 + readonly defaultValue?: JsonValue | null; 5910 + readonly options?: ReadonlyArray<{ 5911 + readonly __typename: 'ActionParameterOption'; 5912 + readonly value: string; 5913 + readonly label: string; 5914 + }> | null; 5915 + }>; 5746 5916 readonly itemTypes: ReadonlyArray< 5747 5917 | { readonly __typename: 'ContentItemType'; readonly id: string } 5748 5918 | { readonly __typename: 'ThreadItemType'; readonly id: string } ··· 5753 5923 readonly __typename: 'EnqueueAuthorToMrtAction'; 5754 5924 readonly id: string; 5755 5925 readonly name: string; 5926 + readonly parameters: ReadonlyArray<{ 5927 + readonly __typename: 'ActionParameter'; 5928 + readonly name: string; 5929 + readonly displayName: string; 5930 + readonly description?: string | null; 5931 + readonly type: GQLActionParameterType; 5932 + readonly required: boolean; 5933 + readonly min?: number | null; 5934 + readonly max?: number | null; 5935 + readonly maxLength?: number | null; 5936 + readonly defaultValue?: JsonValue | null; 5937 + readonly options?: ReadonlyArray<{ 5938 + readonly __typename: 'ActionParameterOption'; 5939 + readonly value: string; 5940 + readonly label: string; 5941 + }> | null; 5942 + }>; 5756 5943 readonly itemTypes: ReadonlyArray< 5757 5944 | { readonly __typename: 'ContentItemType'; readonly id: string } 5758 5945 | { readonly __typename: 'ThreadItemType'; readonly id: string } ··· 5763 5950 readonly __typename: 'EnqueueToMrtAction'; 5764 5951 readonly id: string; 5765 5952 readonly name: string; 5953 + readonly parameters: ReadonlyArray<{ 5954 + readonly __typename: 'ActionParameter'; 5955 + readonly name: string; 5956 + readonly displayName: string; 5957 + readonly description?: string | null; 5958 + readonly type: GQLActionParameterType; 5959 + readonly required: boolean; 5960 + readonly min?: number | null; 5961 + readonly max?: number | null; 5962 + readonly maxLength?: number | null; 5963 + readonly defaultValue?: JsonValue | null; 5964 + readonly options?: ReadonlyArray<{ 5965 + readonly __typename: 'ActionParameterOption'; 5966 + readonly value: string; 5967 + readonly label: string; 5968 + }> | null; 5969 + }>; 5766 5970 readonly itemTypes: ReadonlyArray< 5767 5971 | { readonly __typename: 'ContentItemType'; readonly id: string } 5768 5972 | { readonly __typename: 'ThreadItemType'; readonly id: string } ··· 5773 5977 readonly __typename: 'EnqueueToNcmecAction'; 5774 5978 readonly id: string; 5775 5979 readonly name: string; 5980 + readonly parameters: ReadonlyArray<{ 5981 + readonly __typename: 'ActionParameter'; 5982 + readonly name: string; 5983 + readonly displayName: string; 5984 + readonly description?: string | null; 5985 + readonly type: GQLActionParameterType; 5986 + readonly required: boolean; 5987 + readonly min?: number | null; 5988 + readonly max?: number | null; 5989 + readonly maxLength?: number | null; 5990 + readonly defaultValue?: JsonValue | null; 5991 + readonly options?: ReadonlyArray<{ 5992 + readonly __typename: 'ActionParameterOption'; 5993 + readonly value: string; 5994 + readonly label: string; 5995 + }> | null; 5996 + }>; 5776 5997 readonly itemTypes: ReadonlyArray< 5777 5998 | { readonly __typename: 'ContentItemType'; readonly id: string } 5778 5999 | { readonly __typename: 'ThreadItemType'; readonly id: string } ··· 11664 11885 } | null; 11665 11886 }; 11666 11887 11667 - export type GQLActionsWithCustomParamsQueryVariables = Exact<{ 11668 - [key: string]: never; 11669 - }>; 11670 - 11671 - export type GQLActionsWithCustomParamsQuery = { 11672 - readonly __typename: 'Query'; 11673 - readonly myOrg?: { 11674 - readonly __typename: 'Org'; 11675 - readonly actions: ReadonlyArray< 11676 - | { 11677 - readonly __typename: 'CustomAction'; 11678 - readonly id: string; 11679 - readonly name: string; 11680 - readonly customMrtApiParams: ReadonlyArray<{ 11681 - readonly __typename: 'CustomMrtApiParamSpec'; 11682 - readonly name: string; 11683 - readonly type: string; 11684 - readonly displayName: string; 11685 - } | null>; 11686 - } 11687 - | { 11688 - readonly __typename: 'EnqueueAuthorToMrtAction'; 11689 - readonly id: string; 11690 - readonly name: string; 11691 - } 11692 - | { 11693 - readonly __typename: 'EnqueueToMrtAction'; 11694 - readonly id: string; 11695 - readonly name: string; 11696 - } 11697 - | { 11698 - readonly __typename: 'EnqueueToNcmecAction'; 11699 - readonly id: string; 11700 - readonly name: string; 11701 - } 11702 - >; 11703 - } | null; 11704 - }; 11705 - 11706 11888 export type GQLManualReviewJobInfoQueryVariables = Exact<{ 11707 11889 jobIds?: InputMaybe< 11708 11890 ReadonlyArray<Scalars['ID']['input']> | Scalars['ID']['input'] ··· 11926 12108 readonly name: string; 11927 12109 } 11928 12110 >; 11929 - readonly customMrtApiParams: ReadonlyArray<{ 11930 - readonly __typename: 'CustomMrtApiParamSpec'; 12111 + readonly parameters: ReadonlyArray<{ 12112 + readonly __typename: 'ActionParameter'; 11931 12113 readonly name: string; 11932 - readonly type: string; 11933 12114 readonly displayName: string; 11934 - } | null>; 12115 + readonly description?: string | null; 12116 + readonly type: GQLActionParameterType; 12117 + readonly required: boolean; 12118 + readonly min?: number | null; 12119 + readonly max?: number | null; 12120 + readonly maxLength?: number | null; 12121 + readonly defaultValue?: JsonValue | null; 12122 + readonly options?: ReadonlyArray<{ 12123 + readonly __typename: 'ActionParameterOption'; 12124 + readonly value: string; 12125 + readonly label: string; 12126 + }> | null; 12127 + }>; 11935 12128 } 11936 12129 | { 11937 12130 readonly __typename: 'EnqueueAuthorToMrtAction'; ··· 24178 24371 callbackUrl 24179 24372 callbackUrlHeaders 24180 24373 callbackUrlBody 24374 + parameters { 24375 + name 24376 + displayName 24377 + description 24378 + type 24379 + required 24380 + options { 24381 + value 24382 + label 24383 + } 24384 + min 24385 + max 24386 + maxLength 24387 + defaultValue 24388 + } 24181 24389 } 24182 24390 `; 24183 24391 export const GQLManualReviewDecisionComponentFieldsFragmentDoc = gql` ··· 28504 28712 ... on ActionBase { 28505 28713 id 28506 28714 name 28715 + parameters { 28716 + name 28717 + displayName 28718 + description 28719 + type 28720 + required 28721 + options { 28722 + value 28723 + label 28724 + } 28725 + min 28726 + max 28727 + maxLength 28728 + defaultValue 28729 + } 28507 28730 } 28508 28731 ... on CustomAction { 28509 28732 itemTypes { ··· 33338 33561 GQLSetModeratorSafetySettingsMutation, 33339 33562 GQLSetModeratorSafetySettingsMutationVariables 33340 33563 >; 33341 - export const GQLActionsWithCustomParamsDocument = gql` 33342 - query ActionsWithCustomParams { 33343 - myOrg { 33344 - actions { 33345 - ... on ActionBase { 33346 - id 33347 - name 33348 - } 33349 - ... on CustomAction { 33350 - id 33351 - name 33352 - customMrtApiParams { 33353 - name 33354 - type 33355 - displayName 33356 - } 33357 - } 33358 - } 33359 - } 33360 - } 33361 - `; 33362 - 33363 - /** 33364 - * __useGQLActionsWithCustomParamsQuery__ 33365 - * 33366 - * To run a query within a React component, call `useGQLActionsWithCustomParamsQuery` and pass it any options that fit your needs. 33367 - * When your component renders, `useGQLActionsWithCustomParamsQuery` returns an object from Apollo Client that contains loading, error, and data properties 33368 - * you can use to render your UI. 33369 - * 33370 - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; 33371 - * 33372 - * @example 33373 - * const { data, loading, error } = useGQLActionsWithCustomParamsQuery({ 33374 - * variables: { 33375 - * }, 33376 - * }); 33377 - */ 33378 - export function useGQLActionsWithCustomParamsQuery( 33379 - baseOptions?: Apollo.QueryHookOptions< 33380 - GQLActionsWithCustomParamsQuery, 33381 - GQLActionsWithCustomParamsQueryVariables 33382 - >, 33383 - ) { 33384 - const options = { ...defaultOptions, ...baseOptions }; 33385 - return Apollo.useQuery< 33386 - GQLActionsWithCustomParamsQuery, 33387 - GQLActionsWithCustomParamsQueryVariables 33388 - >(GQLActionsWithCustomParamsDocument, options); 33389 - } 33390 - export function useGQLActionsWithCustomParamsLazyQuery( 33391 - baseOptions?: Apollo.LazyQueryHookOptions< 33392 - GQLActionsWithCustomParamsQuery, 33393 - GQLActionsWithCustomParamsQueryVariables 33394 - >, 33395 - ) { 33396 - const options = { ...defaultOptions, ...baseOptions }; 33397 - return Apollo.useLazyQuery< 33398 - GQLActionsWithCustomParamsQuery, 33399 - GQLActionsWithCustomParamsQueryVariables 33400 - >(GQLActionsWithCustomParamsDocument, options); 33401 - } 33402 - // @ts-ignore 33403 - export function useGQLActionsWithCustomParamsSuspenseQuery( 33404 - baseOptions?: Apollo.SuspenseQueryHookOptions< 33405 - GQLActionsWithCustomParamsQuery, 33406 - GQLActionsWithCustomParamsQueryVariables 33407 - >, 33408 - ): Apollo.UseSuspenseQueryResult< 33409 - GQLActionsWithCustomParamsQuery, 33410 - GQLActionsWithCustomParamsQueryVariables 33411 - >; 33412 - export function useGQLActionsWithCustomParamsSuspenseQuery( 33413 - baseOptions?: 33414 - | Apollo.SkipToken 33415 - | Apollo.SuspenseQueryHookOptions< 33416 - GQLActionsWithCustomParamsQuery, 33417 - GQLActionsWithCustomParamsQueryVariables 33418 - >, 33419 - ): Apollo.UseSuspenseQueryResult< 33420 - GQLActionsWithCustomParamsQuery | undefined, 33421 - GQLActionsWithCustomParamsQueryVariables 33422 - >; 33423 - export function useGQLActionsWithCustomParamsSuspenseQuery( 33424 - baseOptions?: 33425 - | Apollo.SkipToken 33426 - | Apollo.SuspenseQueryHookOptions< 33427 - GQLActionsWithCustomParamsQuery, 33428 - GQLActionsWithCustomParamsQueryVariables 33429 - >, 33430 - ) { 33431 - const options = 33432 - baseOptions === Apollo.skipToken 33433 - ? baseOptions 33434 - : { ...defaultOptions, ...baseOptions }; 33435 - return Apollo.useSuspenseQuery< 33436 - GQLActionsWithCustomParamsQuery, 33437 - GQLActionsWithCustomParamsQueryVariables 33438 - >(GQLActionsWithCustomParamsDocument, options); 33439 - } 33440 - export type GQLActionsWithCustomParamsQueryHookResult = ReturnType< 33441 - typeof useGQLActionsWithCustomParamsQuery 33442 - >; 33443 - export type GQLActionsWithCustomParamsLazyQueryHookResult = ReturnType< 33444 - typeof useGQLActionsWithCustomParamsLazyQuery 33445 - >; 33446 - export type GQLActionsWithCustomParamsSuspenseQueryHookResult = ReturnType< 33447 - typeof useGQLActionsWithCustomParamsSuspenseQuery 33448 - >; 33449 - export type GQLActionsWithCustomParamsQueryResult = Apollo.QueryResult< 33450 - GQLActionsWithCustomParamsQuery, 33451 - GQLActionsWithCustomParamsQueryVariables 33452 - >; 33453 33564 export const GQLManualReviewJobInfoDocument = gql` 33454 33565 query ManualReviewJobInfo($jobIds: [ID!]) { 33455 33566 myOrg { ··· 33486 33597 name 33487 33598 } 33488 33599 } 33489 - customMrtApiParams { 33600 + parameters { 33490 33601 name 33602 + displayName 33603 + description 33491 33604 type 33492 - displayName 33605 + required 33606 + options { 33607 + value 33608 + label 33609 + } 33610 + min 33611 + max 33612 + maxLength 33613 + defaultValue 33493 33614 } 33494 33615 } 33495 33616 } ··· 43150 43271 getSkipsForRecentDecisions: 'getSkipsForRecentDecisions', 43151 43272 GetDecidedJob: 'GetDecidedJob', 43152 43273 ManualReviewSafetySettings: 'ManualReviewSafetySettings', 43153 - ActionsWithCustomParams: 'ActionsWithCustomParams', 43154 43274 ManualReviewJobInfo: 'ManualReviewJobInfo', 43155 43275 getRelatedItems: 'getRelatedItems', 43156 43276 GetCommentsForJob: 'GetCommentsForJob',
+43 -1
client/src/webpages/dashboard/actions/ActionForm.tsx
··· 24 24 } from '../../../graphql/generated'; 25 25 import { userHasPermissions } from '../../../routing/permissions'; 26 26 import { prettyPrintJsonValue } from '../../../utils/string'; 27 + import ActionParametersEditor, { 28 + type ActionParameterDraft, 29 + fromGraphQLParameters, 30 + toMutationInput, 31 + validateDrafts, 32 + } from './ActionParametersEditor'; 27 33 28 34 const { Option } = Select; 29 35 ··· 40 46 callbackUrl 41 47 callbackUrlHeaders 42 48 callbackUrlBody 49 + parameters { 50 + name 51 + displayName 52 + description 53 + type 54 + required 55 + options { 56 + value 57 + label 58 + } 59 + min 60 + max 61 + maxLength 62 + defaultValue 63 + } 43 64 } 44 65 45 66 query Action($id: ID!) { ··· 120 141 const [actionCallbackUrlBody, setActionCallbackUrlBody] = useState< 121 142 string | undefined 122 143 >(undefined); 144 + const [actionParameters, setActionParameters] = useState< 145 + ActionParameterDraft[] 146 + >([]); 123 147 124 148 const showModal = () => { 125 149 setModalVisible(true); ··· 190 214 ? prettyPrintJsonValue(action.callbackUrlBody) 191 215 : undefined, 192 216 ); 217 + setActionParameters(fromGraphQLParameters(action.parameters)); 193 218 }, [action]); 194 219 195 220 if (actionQueryError ?? actionFormQueryError) { ··· 219 244 callbackUrlBody: actionCallbackUrlBody 220 245 ? JSON.parse(actionCallbackUrlBody) 221 246 : undefined, 247 + parameters: toMutationInput(actionParameters), 222 248 }, 223 249 }, 224 250 refetchQueries: [namedOperations.Query.Actions], ··· 239 265 callbackUrlBody: actionCallbackUrlBody 240 266 ? JSON.parse(actionCallbackUrlBody) 241 267 : undefined, 268 + parameters: toMutationInput(actionParameters), 242 269 }, 243 270 }, 244 271 refetchQueries: [ ··· 416 443 {divider()} 417 444 {callbackUrlInput} 418 445 {divider()} 446 + <FormSectionHeader 447 + title="Parameters (Optional)" 448 + subtitle="Define parameters that moderators will be prompted to fill in when running this action. Their values are merged into the webhook body under each parameter's name." 449 + /> 450 + <ActionParametersEditor 451 + value={actionParameters} 452 + onChange={setActionParameters} 453 + disabled={!canEditActions} 454 + /> 455 + {divider()} 419 456 <CoopButton 420 457 title={id == null ? 'Create Action' : 'Save Changes'} 421 458 disabled={ ··· 424 461 !actionItemTypeIds?.length || 425 462 !actionCallbackUrl || 426 463 !validateJson(actionCallbackUrlHeaders) || 427 - !validateJson(actionCallbackUrlBody) 464 + !validateJson(actionCallbackUrlBody) || 465 + validateDrafts(actionParameters) !== null 428 466 } 429 467 loading={createMutationLoading || updateMutationLoading} 430 468 disabledTooltipTitle={(() => { ··· 445 483 } 446 484 if (!validateJson(actionCallbackUrlBody)) { 447 485 return 'Please enter a valid JSON for the callback URL body.'; 486 + } 487 + const paramError = validateDrafts(actionParameters); 488 + if (paramError !== null) { 489 + return paramError; 448 490 } 449 491 })()} 450 492 disabledTooltipPlacement="bottomLeft"
+825
client/src/webpages/dashboard/actions/ActionParametersEditor.tsx
··· 1 + import { ChevronsUpDown, Plus, Trash2 } from 'lucide-react'; 2 + import { useId, useMemo } from 'react'; 3 + 4 + import { Button } from '@/coop-ui/Button'; 5 + import { Checkbox } from '@/coop-ui/Checkbox'; 6 + import { Input } from '@/coop-ui/Input'; 7 + import { Label } from '@/coop-ui/Label'; 8 + import { Popover, PopoverContent, PopoverTrigger } from '@/coop-ui/Popover'; 9 + import { 10 + Select, 11 + SelectContent, 12 + SelectItem, 13 + SelectTrigger, 14 + SelectValue, 15 + } from '@/coop-ui/Select'; 16 + import { Switch } from '@/coop-ui/Switch'; 17 + import { cn } from '@/lib/utils'; 18 + 19 + import { 20 + type GQLActionParameterInput, 21 + type GQLActionParameterOptionInput, 22 + GQLActionParameterType, 23 + } from '../../../graphql/generated'; 24 + 25 + // Mirror of the server-side pattern in `actionParametersValidation.ts`. Allows 26 + // snake_case, kebab-case, and dotted namespacing; rejects whitespace, quotes, 27 + // and brackets that would break webhook-payload key access. 28 + const PARAMETER_NAME_PATTERN = /^[a-zA-Z0-9_.\-]+$/; 29 + 30 + // Radix `SelectItem` rejects `value=""`, so we use a sentinel string for the 31 + // "no default" option in BOOLEAN / SELECT default-value pickers. The sentinel 32 + // is never serialized — it's mapped back to `undefined` in the change handler. 33 + const NO_DEFAULT = '__no_default__'; 34 + 35 + export type ActionParameterDraft = { 36 + name: string; 37 + displayName: string; 38 + description?: string; 39 + type: GQLActionParameterType; 40 + required: boolean; 41 + options?: GQLActionParameterOptionInput[]; 42 + min?: number; 43 + max?: number; 44 + maxLength?: number; 45 + // Stored in the parameter's native shape (string / number / boolean / 46 + // string[]); each `type` renders the matching input widget so we never 47 + // need to coerce strings on submit. 48 + defaultValue?: unknown; 49 + }; 50 + 51 + /** 52 + * Repeating editor for an action's runtime parameters. Each row defines one 53 + * parameter spec; the moderator will be prompted for a value at execution 54 + * time. The serialized value (via `toMutationInput`) feeds 55 + * `CreateActionInput.parameters` / `UpdateActionInput.parameters`. 56 + */ 57 + export default function ActionParametersEditor({ 58 + value, 59 + onChange, 60 + disabled, 61 + }: { 62 + value: ActionParameterDraft[]; 63 + onChange: (next: ActionParameterDraft[]) => void; 64 + disabled?: boolean; 65 + }) { 66 + const updateAt = (index: number, patch: Partial<ActionParameterDraft>) => { 67 + const next = value.slice(); 68 + next[index] = { ...next[index], ...patch }; 69 + onChange(next); 70 + }; 71 + 72 + const removeAt = (index: number) => { 73 + onChange(value.filter((_, i) => i !== index)); 74 + }; 75 + 76 + const addParameter = () => { 77 + onChange([ 78 + ...value, 79 + { 80 + name: '', 81 + displayName: '', 82 + type: GQLActionParameterType.String, 83 + required: false, 84 + }, 85 + ]); 86 + }; 87 + 88 + return ( 89 + <div className="flex flex-col gap-4"> 90 + {value.map((param, index) => ( 91 + <ParameterRow 92 + key={index} 93 + param={param} 94 + disabled={disabled} 95 + onChange={(patch) => updateAt(index, patch)} 96 + onRemove={() => removeAt(index)} 97 + /> 98 + ))} 99 + <Button 100 + type="button" 101 + variant="outline" 102 + color="gray" 103 + size="sm" 104 + startIcon={Plus} 105 + disabled={disabled} 106 + onClick={addParameter} 107 + className="self-start" 108 + > 109 + Add parameter 110 + </Button> 111 + </div> 112 + ); 113 + } 114 + 115 + function ParameterRow({ 116 + param, 117 + disabled, 118 + onChange, 119 + onRemove, 120 + }: { 121 + param: ActionParameterDraft; 122 + disabled?: boolean; 123 + onChange: (patch: Partial<ActionParameterDraft>) => void; 124 + onRemove: () => void; 125 + }) { 126 + const id = useId(); 127 + const isSelectLike = 128 + param.type === GQLActionParameterType.Select || 129 + param.type === GQLActionParameterType.Multiselect; 130 + const isNumber = param.type === GQLActionParameterType.Number; 131 + const isString = param.type === GQLActionParameterType.String; 132 + const hasConstraints = isString || isNumber; 133 + 134 + return ( 135 + <div className="rounded-xl border border-gray-200 p-4"> 136 + <div className="grid grid-cols-1 gap-x-3 gap-y-3 md:grid-cols-12"> 137 + <Field label="Name (key)" htmlFor={`${id}-name`} className="md:col-span-4"> 138 + <Input 139 + id={`${id}-name`} 140 + value={param.name} 141 + placeholder="my-param-key" 142 + disabled={disabled} 143 + onChange={(e) => onChange({ name: e.target.value })} 144 + /> 145 + </Field> 146 + <Field 147 + label="Display name" 148 + htmlFor={`${id}-display`} 149 + className="md:col-span-5" 150 + > 151 + <Input 152 + id={`${id}-display`} 153 + value={param.displayName} 154 + placeholder="Display name on form" 155 + disabled={disabled} 156 + onChange={(e) => onChange({ displayName: e.target.value })} 157 + /> 158 + </Field> 159 + <Field label="Type" htmlFor={`${id}-type`} className="md:col-span-2"> 160 + <Select 161 + value={param.type} 162 + disabled={disabled} 163 + onValueChange={(next) => 164 + onChange({ 165 + type: next as GQLActionParameterType, 166 + // Reset type-specific fields on type change so we never carry 167 + // STRING-only `maxLength` into a NUMBER, etc. 168 + options: undefined, 169 + min: undefined, 170 + max: undefined, 171 + maxLength: undefined, 172 + defaultValue: undefined, 173 + }) 174 + } 175 + > 176 + <SelectTrigger id={`${id}-type`}> 177 + <SelectValue /> 178 + </SelectTrigger> 179 + <SelectContent> 180 + {Object.values(GQLActionParameterType).map((t) => ( 181 + <SelectItem key={t} value={t}> 182 + {t} 183 + </SelectItem> 184 + ))} 185 + </SelectContent> 186 + </Select> 187 + </Field> 188 + <Field 189 + label="Required" 190 + htmlFor={`${id}-required`} 191 + className="md:col-span-1" 192 + align="start" 193 + > 194 + <Switch 195 + id={`${id}-required`} 196 + checked={param.required} 197 + disabled={disabled} 198 + onCheckedChange={(required) => onChange({ required })} 199 + /> 200 + </Field> 201 + 202 + <Field 203 + label="Description (optional)" 204 + htmlFor={`${id}-desc`} 205 + className="md:col-span-6" 206 + > 207 + <Input 208 + id={`${id}-desc`} 209 + value={param.description ?? ''} 210 + disabled={disabled} 211 + onChange={(e) => 212 + onChange({ 213 + description: e.target.value === '' ? undefined : e.target.value, 214 + }) 215 + } 216 + /> 217 + </Field> 218 + {hasConstraints ? ( 219 + <ConstraintsRow 220 + id={id} 221 + param={param} 222 + isString={isString} 223 + isNumber={isNumber} 224 + disabled={disabled} 225 + onChange={onChange} 226 + /> 227 + ) : ( 228 + // Keep the second row balanced when no constraints exist for the type. 229 + <div className="hidden md:col-span-6 md:block" /> 230 + )} 231 + 232 + <Field 233 + label="Default value (optional)" 234 + htmlFor={`${id}-default`} 235 + className="md:col-span-12" 236 + > 237 + <DefaultValueInput 238 + id={`${id}-default`} 239 + param={param} 240 + disabled={disabled} 241 + onChange={(defaultValue) => onChange({ defaultValue })} 242 + /> 243 + </Field> 244 + 245 + {isSelectLike && ( 246 + <Field label="Options" className="md:col-span-12"> 247 + <OptionsEditor 248 + options={param.options ?? []} 249 + disabled={disabled} 250 + onChange={(options) => onChange({ options })} 251 + /> 252 + </Field> 253 + )} 254 + </div> 255 + 256 + <div className="mt-4 flex justify-end"> 257 + <Button 258 + type="button" 259 + variant="outline" 260 + color="red" 261 + size="sm" 262 + startIcon={Trash2} 263 + disabled={disabled} 264 + onClick={onRemove} 265 + > 266 + Remove parameter 267 + </Button> 268 + </div> 269 + </div> 270 + ); 271 + } 272 + 273 + function ConstraintsRow({ 274 + id, 275 + param, 276 + isString, 277 + isNumber, 278 + disabled, 279 + onChange, 280 + }: { 281 + id: string; 282 + param: ActionParameterDraft; 283 + isString: boolean; 284 + isNumber: boolean; 285 + disabled?: boolean; 286 + onChange: (patch: Partial<ActionParameterDraft>) => void; 287 + }) { 288 + if (isString) { 289 + return ( 290 + <Field 291 + label="Max length (optional)" 292 + htmlFor={`${id}-maxlen`} 293 + className="md:col-span-6" 294 + > 295 + <NumberInput 296 + id={`${id}-maxlen`} 297 + value={param.maxLength} 298 + min={1} 299 + max={100000} 300 + disabled={disabled} 301 + onChange={(maxLength) => onChange({ maxLength })} 302 + /> 303 + </Field> 304 + ); 305 + } 306 + if (isNumber) { 307 + return ( 308 + <> 309 + <Field label="Min (optional)" htmlFor={`${id}-min`} className="md:col-span-3"> 310 + <NumberInput 311 + id={`${id}-min`} 312 + value={param.min} 313 + disabled={disabled} 314 + onChange={(min) => onChange({ min })} 315 + /> 316 + </Field> 317 + <Field label="Max (optional)" htmlFor={`${id}-max`} className="md:col-span-3"> 318 + <NumberInput 319 + id={`${id}-max`} 320 + value={param.max} 321 + disabled={disabled} 322 + onChange={(max) => onChange({ max })} 323 + /> 324 + </Field> 325 + </> 326 + ); 327 + } 328 + return null; 329 + } 330 + 331 + function DefaultValueInput({ 332 + id, 333 + param, 334 + disabled, 335 + onChange, 336 + }: { 337 + id: string; 338 + param: ActionParameterDraft; 339 + disabled?: boolean; 340 + onChange: (next: unknown) => void; 341 + }) { 342 + switch (param.type) { 343 + case GQLActionParameterType.String: { 344 + const value = typeof param.defaultValue === 'string' ? param.defaultValue : ''; 345 + return ( 346 + <Input 347 + id={id} 348 + value={value} 349 + disabled={disabled} 350 + onChange={(e) => 351 + onChange(e.target.value === '' ? undefined : e.target.value) 352 + } 353 + /> 354 + ); 355 + } 356 + case GQLActionParameterType.Number: { 357 + const value = 358 + typeof param.defaultValue === 'number' ? param.defaultValue : undefined; 359 + return ( 360 + <NumberInput 361 + id={id} 362 + value={value} 363 + min={param.min} 364 + max={param.max} 365 + disabled={disabled} 366 + onChange={(next) => onChange(next)} 367 + /> 368 + ); 369 + } 370 + case GQLActionParameterType.Boolean: { 371 + const dv = param.defaultValue; 372 + const value = dv === true ? 'true' : dv === false ? 'false' : NO_DEFAULT; 373 + return ( 374 + <Select 375 + value={value} 376 + disabled={disabled} 377 + onValueChange={(next) => 378 + onChange( 379 + next === 'true' ? true : next === 'false' ? false : undefined, 380 + ) 381 + } 382 + > 383 + <SelectTrigger id={id}> 384 + <SelectValue /> 385 + </SelectTrigger> 386 + <SelectContent> 387 + <SelectItem value={NO_DEFAULT}>No default</SelectItem> 388 + <SelectItem value="true">true</SelectItem> 389 + <SelectItem value="false">false</SelectItem> 390 + </SelectContent> 391 + </Select> 392 + ); 393 + } 394 + case GQLActionParameterType.Select: { 395 + // Skip half-typed option rows: Radix `SelectItem` rejects `value=""`, 396 + // and an unlabelled item isn't pickable anyway. 397 + const options = (param.options ?? []).filter((opt) => opt.value !== ''); 398 + if (options.length === 0) { 399 + return <EmptyOptionsHint />; 400 + } 401 + const dv = param.defaultValue; 402 + const value = typeof dv === 'string' ? dv : NO_DEFAULT; 403 + return ( 404 + <Select 405 + value={value} 406 + disabled={disabled} 407 + onValueChange={(next) => 408 + onChange(next === NO_DEFAULT ? undefined : next) 409 + } 410 + > 411 + <SelectTrigger id={id}> 412 + <SelectValue /> 413 + </SelectTrigger> 414 + <SelectContent> 415 + <SelectItem value={NO_DEFAULT}>No default</SelectItem> 416 + {options.map((opt) => ( 417 + <SelectItem key={opt.value} value={opt.value}> 418 + {opt.label !== '' ? opt.label : opt.value} 419 + </SelectItem> 420 + ))} 421 + </SelectContent> 422 + </Select> 423 + ); 424 + } 425 + case GQLActionParameterType.Multiselect: { 426 + // Same filter as SELECT: half-typed option rows aren't pickable and 427 + // would collide on `key={opt.value}`. 428 + const options = (param.options ?? []).filter((opt) => opt.value !== ''); 429 + if (options.length === 0) { 430 + return <EmptyOptionsHint />; 431 + } 432 + const selected = new Set<string>( 433 + Array.isArray(param.defaultValue) 434 + ? param.defaultValue.filter((v): v is string => typeof v === 'string') 435 + : [], 436 + ); 437 + return ( 438 + <MultiSelectDropdown 439 + id={id} 440 + options={options} 441 + selected={selected} 442 + disabled={disabled} 443 + onChange={onChange} 444 + /> 445 + ); 446 + } 447 + default: 448 + return null; 449 + } 450 + } 451 + 452 + function EmptyOptionsHint() { 453 + return ( 454 + <p className="text-sm text-gray-500"> 455 + Add options below before picking a default. 456 + </p> 457 + ); 458 + } 459 + 460 + /** 461 + * Dropdown for picking multiple option values. coop-ui's `Select` is 462 + * single-value (Radix), so we mimic the `SelectTrigger` chrome with a button 463 + * that opens a `Popover` containing a checkbox list. Visually consistent with 464 + * the SELECT default-value picker. 465 + */ 466 + function MultiSelectDropdown({ 467 + id, 468 + options, 469 + selected, 470 + disabled, 471 + onChange, 472 + }: { 473 + id?: string; 474 + options: readonly GQLActionParameterOptionInput[]; 475 + selected: ReadonlySet<string>; 476 + disabled?: boolean; 477 + onChange: (next: string[] | undefined) => void; 478 + }) { 479 + const labelFor = (opt: GQLActionParameterOptionInput) => 480 + opt.label !== '' ? opt.label : opt.value; 481 + const selectedLabels = options 482 + .filter((opt) => selected.has(opt.value)) 483 + .map(labelFor); 484 + const summary = 485 + selectedLabels.length === 0 486 + ? 'No default' 487 + : selectedLabels.length <= 2 488 + ? selectedLabels.join(', ') 489 + : `${selectedLabels.slice(0, 2).join(', ')}, +${selectedLabels.length - 2} more`; 490 + 491 + return ( 492 + <Popover> 493 + <PopoverTrigger asChild> 494 + <button 495 + type="button" 496 + id={id} 497 + disabled={disabled} 498 + className={cn( 499 + 'flex w-full items-center justify-between whitespace-nowrap rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm font-normal transition-colors', 500 + 'hover:border-gray-300', 501 + 'focus:z-10 focus:border-indigo-500 focus:shadow-focus-indigo focus:outline-none', 502 + 'disabled:cursor-not-allowed disabled:opacity-50', 503 + selectedLabels.length === 0 && 'text-gray-400', 504 + )} 505 + > 506 + <span className="truncate">{summary}</span> 507 + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 508 + </button> 509 + </PopoverTrigger> 510 + <PopoverContent 511 + align="start" 512 + className="p-1" 513 + style={{ width: 'var(--radix-popover-trigger-width)' }} 514 + > 515 + <div className="flex max-h-72 flex-col overflow-y-auto"> 516 + {options.map((opt) => { 517 + const checkboxId = `${id}-${opt.value}`; 518 + const isChecked = selected.has(opt.value); 519 + return ( 520 + <Label 521 + key={opt.value} 522 + htmlFor={checkboxId} 523 + className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm font-normal text-gray-700 hover:bg-gray-100" 524 + > 525 + <Checkbox 526 + id={checkboxId} 527 + checked={isChecked} 528 + onCheckedChange={(checked) => { 529 + const next = new Set(selected); 530 + if (checked) next.add(opt.value); 531 + else next.delete(opt.value); 532 + onChange(next.size === 0 ? undefined : Array.from(next)); 533 + }} 534 + /> 535 + <span>{labelFor(opt)}</span> 536 + </Label> 537 + ); 538 + })} 539 + </div> 540 + </PopoverContent> 541 + </Popover> 542 + ); 543 + } 544 + 545 + function NumberInput({ 546 + id, 547 + value, 548 + min, 549 + max, 550 + disabled, 551 + onChange, 552 + }: { 553 + id?: string; 554 + value: number | undefined; 555 + min?: number; 556 + max?: number; 557 + disabled?: boolean; 558 + onChange: (next: number | undefined) => void; 559 + }) { 560 + return ( 561 + <Input 562 + id={id} 563 + type="number" 564 + value={value ?? ''} 565 + min={min} 566 + max={max} 567 + disabled={disabled} 568 + onChange={(e) => { 569 + const raw = e.target.value; 570 + if (raw === '') { 571 + onChange(undefined); 572 + return; 573 + } 574 + const parsed = Number(raw); 575 + if (!Number.isFinite(parsed)) { 576 + onChange(undefined); 577 + return; 578 + } 579 + // `<input type="number" min/max>` only constrains the spinner UI; 580 + // direct typing can still produce out-of-range values. Clamp here so 581 + // the parent state never sees e.g. a negative `maxLength`. 582 + let clamped = parsed; 583 + if (min !== undefined && clamped < min) clamped = min; 584 + if (max !== undefined && clamped > max) clamped = max; 585 + onChange(clamped); 586 + }} 587 + /> 588 + ); 589 + } 590 + 591 + function Field({ 592 + label, 593 + htmlFor, 594 + children, 595 + className, 596 + align, 597 + }: { 598 + label: string; 599 + htmlFor?: string; 600 + children: React.ReactNode; 601 + className?: string; 602 + // `start` keeps the child at its natural width (e.g. for a `Switch` that 603 + // would otherwise be stretched to the cell width by the parent flex column). 604 + align?: 'start'; 605 + }) { 606 + const alignment = align === 'start' ? 'items-start' : ''; 607 + return ( 608 + <div className={`flex flex-col gap-1.5 ${alignment} ${className ?? ''}`}> 609 + <Label htmlFor={htmlFor} className="text-gray-700"> 610 + {label} 611 + </Label> 612 + {children} 613 + </div> 614 + ); 615 + } 616 + 617 + function OptionsEditor({ 618 + options, 619 + onChange, 620 + disabled, 621 + }: { 622 + options: GQLActionParameterOptionInput[]; 623 + onChange: (next: GQLActionParameterOptionInput[]) => void; 624 + disabled?: boolean; 625 + }) { 626 + const updateAt = ( 627 + index: number, 628 + patch: Partial<GQLActionParameterOptionInput>, 629 + ) => { 630 + const next = options.slice(); 631 + next[index] = { ...next[index], ...patch }; 632 + onChange(next); 633 + }; 634 + return ( 635 + <div className="flex flex-col gap-2"> 636 + {options.map((opt, index) => ( 637 + <div key={index} className="flex items-center gap-2"> 638 + <Input 639 + placeholder="value" 640 + value={opt.value} 641 + disabled={disabled} 642 + onChange={(e) => updateAt(index, { value: e.target.value })} 643 + /> 644 + <Input 645 + placeholder="label" 646 + value={opt.label} 647 + disabled={disabled} 648 + onChange={(e) => updateAt(index, { label: e.target.value })} 649 + /> 650 + <Button 651 + type="button" 652 + variant="ghost" 653 + color="red" 654 + size="icon" 655 + disabled={disabled} 656 + onClick={() => 657 + onChange(options.filter((_, optionIndex) => optionIndex !== index)) 658 + } 659 + aria-label="Remove option" 660 + > 661 + <Trash2 /> 662 + </Button> 663 + </div> 664 + ))} 665 + <Button 666 + type="button" 667 + variant="outline" 668 + color="gray" 669 + size="sm" 670 + startIcon={Plus} 671 + disabled={disabled} 672 + onClick={() => onChange([...options, { value: '', label: '' }])} 673 + className="self-start" 674 + > 675 + Add option 676 + </Button> 677 + </div> 678 + ); 679 + } 680 + 681 + /** 682 + * Convert a draft list to the GraphQL input shape. `defaultValue` is already 683 + * stored in its native type (the editor uses typed widgets), so no coercion 684 + * is needed. 685 + */ 686 + export function toMutationInput( 687 + drafts: readonly ActionParameterDraft[], 688 + ): GQLActionParameterInput[] { 689 + return drafts.map((draft) => ({ 690 + name: draft.name, 691 + displayName: draft.displayName, 692 + description: draft.description, 693 + type: draft.type, 694 + required: draft.required, 695 + options: draft.options, 696 + min: draft.min, 697 + max: draft.max, 698 + maxLength: draft.maxLength, 699 + // The editor only ever stores string / number / boolean / string[] in 700 + // `defaultValue`; all are valid `JsonValue`s for the GQL `JSON` scalar. 701 + defaultValue: draft.defaultValue as GQLActionParameterInput['defaultValue'], 702 + })); 703 + } 704 + 705 + /** 706 + * Validate the in-progress drafts. Returns `null` when the list is submittable, 707 + * or a human-readable message identifying the first problem so the form can 708 + * surface it via the disabled-button tooltip. 709 + * 710 + * Server still re-validates via AJV on write; this is purely for UX. 711 + */ 712 + export function validateDrafts( 713 + drafts: readonly ActionParameterDraft[], 714 + ): string | null { 715 + const seenNames = new Set<string>(); 716 + for (const [index, draft] of drafts.entries()) { 717 + const at = `Parameter #${index + 1}`; 718 + if (!draft.name) return `${at}: name is required.`; 719 + if (!PARAMETER_NAME_PATTERN.test(draft.name)) { 720 + return `${at}: name may only contain letters, digits, _, -, or .`; 721 + } 722 + if (seenNames.has(draft.name)) { 723 + return `${at}: duplicate name "${draft.name}".`; 724 + } 725 + seenNames.add(draft.name); 726 + if (!draft.displayName) return `${at}: display name is required.`; 727 + 728 + if ( 729 + draft.type === GQLActionParameterType.Select || 730 + draft.type === GQLActionParameterType.Multiselect 731 + ) { 732 + if (!draft.options || draft.options.length === 0) { 733 + return `${at}: at least one option is required for ${draft.type}.`; 734 + } 735 + const optionValues = new Set<string>(); 736 + for (const opt of draft.options) { 737 + if (!opt.value || !opt.label) { 738 + return `${at}: each option needs a value and a label.`; 739 + } 740 + if (optionValues.has(opt.value)) { 741 + return `${at}: duplicate option value "${opt.value}".`; 742 + } 743 + optionValues.add(opt.value); 744 + } 745 + } 746 + if ( 747 + draft.type === GQLActionParameterType.Number && 748 + draft.min !== undefined && 749 + draft.max !== undefined && 750 + draft.min > draft.max 751 + ) { 752 + return `${at}: min must be <= max.`; 753 + } 754 + 755 + // Default-value bounds checks. Type-shape is enforced by the typed 756 + // widgets in `DefaultValueInput`, so only range/membership can drift. 757 + const dv = draft.defaultValue; 758 + if (dv !== undefined) { 759 + if (draft.type === GQLActionParameterType.Number && typeof dv === 'number') { 760 + if (draft.min !== undefined && dv < draft.min) { 761 + return `${at}: default below min.`; 762 + } 763 + if (draft.max !== undefined && dv > draft.max) { 764 + return `${at}: default above max.`; 765 + } 766 + } 767 + if ( 768 + draft.type === GQLActionParameterType.String && 769 + typeof dv === 'string' && 770 + draft.maxLength !== undefined && 771 + dv.length > draft.maxLength 772 + ) { 773 + return `${at}: default exceeds maxLength.`; 774 + } 775 + } 776 + } 777 + return null; 778 + } 779 + 780 + /** 781 + * Project an existing parameter (read from the API) into the draft shape used 782 + * by this editor. 783 + */ 784 + export function fromGraphQLParameters( 785 + parameters: ReadonlyArray<{ 786 + readonly name: string; 787 + readonly displayName: string; 788 + readonly description?: string | null; 789 + readonly type: GQLActionParameterType; 790 + readonly required: boolean; 791 + readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }> | null; 792 + readonly min?: number | null; 793 + readonly max?: number | null; 794 + readonly maxLength?: number | null; 795 + readonly defaultValue?: unknown; 796 + }>, 797 + ): ActionParameterDraft[] { 798 + return parameters.map((p) => ({ 799 + name: p.name, 800 + displayName: p.displayName, 801 + description: p.description ?? undefined, 802 + type: p.type, 803 + required: p.required, 804 + options: p.options 805 + ? p.options.map((o) => ({ value: o.value, label: o.label })) 806 + : undefined, 807 + min: p.min ?? undefined, 808 + max: p.max ?? undefined, 809 + maxLength: p.maxLength ?? undefined, 810 + defaultValue: p.defaultValue ?? undefined, 811 + })); 812 + } 813 + 814 + /** 815 + * Memoize a draft list keyed by the GraphQL response array reference. Avoids 816 + * blowing away in-progress local edits when the action query refetches. 817 + */ 818 + export function useParameterDraftsFromAction< 819 + T extends Parameters<typeof fromGraphQLParameters>[0], 820 + >(parameters: T | undefined): ActionParameterDraft[] | undefined { 821 + return useMemo( 822 + () => (parameters === undefined ? undefined : fromGraphQLParameters(parameters)), 823 + [parameters], 824 + ); 825 + }
+110 -1
client/src/webpages/dashboard/bulk_actioning/BulkActioningDashboard.tsx
··· 4 4 import { useState } from 'react'; 5 5 import { Helmet } from 'react-helmet-async'; 6 6 import { Link, useNavigate } from 'react-router-dom'; 7 + import { type JsonObject } from 'type-fest'; 7 8 9 + import ActionParameterInputs, { 10 + type ActionParameterValues, 11 + findMissingRequiredParameters, 12 + } from '../../../components/ActionParameterInputs'; 8 13 import FullScreenLoading from '../../../components/common/FullScreenLoading'; 9 14 import { selectFilterByLabelOption } from '../components/antDesignUtils'; 10 15 import CoopButton from '../components/CoopButton'; ··· 40 45 ... on ActionBase { 41 46 id 42 47 name 48 + parameters { 49 + name 50 + displayName 51 + description 52 + type 53 + required 54 + options { 55 + value 56 + label 57 + } 58 + min 59 + max 60 + maxLength 61 + defaultValue 62 + } 43 63 } 44 64 ... on CustomAction { 45 65 itemTypes { ··· 103 123 const [selectedActionIds, setSelectedActionIds] = useState<string[]>([]); 104 124 const [selectedPolicyIds, setSelectedPolicyIds] = useState<string[]>([]); 105 125 const [showSubmissionModal, setShowSubmissionModal] = useState(false); 126 + const [parametersByActionId, setParametersByActionId] = useState< 127 + Record<string, ActionParameterValues> 128 + >({}); 129 + const [moderatorNote, setModeratorNote] = useState<string>(''); 106 130 107 131 const { data: queryData, loading: queryLoading } = 108 132 useGQLBulkActionsFormDataQuery(); ··· 125 149 setInputIds([]); 126 150 setSelectedActionIds([]); 127 151 setSelectedPolicyIds([]); 152 + setParametersByActionId({}); 153 + setModeratorNote(''); 128 154 bulkActionMutationReset(); 129 155 }; 130 156 ··· 148 174 actionIds: selectedActionIds, 149 175 itemIds: inputIds, 150 176 policyIds: selectedPolicyIds ?? [], 177 + // See `ItemAction.tsx` for why this cast is necessary; the runtime 178 + // shape is JSON-serializable but TS can't infer it from the 179 + // discriminated parameter-type union without a heavy generic. 180 + parameters: 181 + Object.keys(parametersByActionId).length > 0 182 + ? (parametersByActionId as unknown as JsonObject) 183 + : undefined, 184 + note: moderatorNote.trim() === '' ? undefined : moderatorNote.trim(), 151 185 }, 152 186 }, 153 187 }); ··· 205 239 ), 206 240 ) 207 241 : []; 242 + 243 + // Plain non-memoized derivations: cheap to compute every render and 244 + // unconditional `useMemo` would have to live above the `if (queryLoading)` 245 + // early return, which would force restructuring this whole file. 246 + const selectedActionsWithParams = actions.filter( 247 + (action) => 248 + selectedActionIds.includes(action.id) && action.parameters.length > 0, 249 + ); 250 + const missingRequiredLabels: string[] = (() => { 251 + const out: string[] = []; 252 + for (const action of selectedActionsWithParams) { 253 + const missing = findMissingRequiredParameters( 254 + action.parameters, 255 + parametersByActionId[action.id] ?? {}, 256 + ); 257 + out.push(...missing.map((label) => `"${action.name}" → ${label}`)); 258 + } 259 + return out; 260 + })(); 208 261 209 262 const actionSelector = ( 210 263 <Select<string[]> ··· 386 439 disabled={ 387 440 !inputIds.length || 388 441 !selectedItemTypeId || 389 - !selectedActionIds.length 442 + !selectedActionIds.length || 443 + missingRequiredLabels.length > 0 444 + } 445 + disabledTooltipTitle={ 446 + missingRequiredLabels.length > 0 447 + ? `Fill in required details: ${missingRequiredLabels.join( 448 + ', ', 449 + )}` 450 + : undefined 390 451 } 391 452 /> 392 453 } ··· 413 474 } 414 475 /> 415 476 {actionSelector} 477 + {selectedActionsWithParams.length > 0 && ( 478 + <div className="mt-6 flex flex-col gap-4"> 479 + <FormSectionHeader 480 + title="Action Details" 481 + subtitle="Fill in the details below for each selected action that needs them." 482 + /> 483 + {selectedActionsWithParams.map((action) => ( 484 + <div 485 + key={action.id} 486 + className="rounded-xl border border-gray-200 p-3" 487 + > 488 + <div className="mb-2 text-sm font-semibold"> 489 + "{action.name}" details 490 + </div> 491 + <ActionParameterInputs 492 + parameters={action.parameters} 493 + values={parametersByActionId[action.id] ?? {}} 494 + onChange={(next) => 495 + setParametersByActionId((prev) => ({ 496 + ...prev, 497 + [action.id]: next, 498 + })) 499 + } 500 + idPrefix={`bulk-action-${action.id}`} 501 + /> 502 + </div> 503 + ))} 504 + </div> 505 + )} 506 + {selectedActionIds.length > 0 && ( 507 + <div className="mt-6 flex flex-col"> 508 + <label 509 + htmlFor="bulk-actioning-moderator-note" 510 + className="mb-1 text-sm font-medium text-gray-700" 511 + > 512 + Note (optional) 513 + </label> 514 + <Input.TextArea 515 + id="bulk-actioning-moderator-note" 516 + placeholder="Sent to the action's webhook as `actorNote` and persisted to the audit log." 517 + rows={2} 518 + maxLength={5000} 519 + value={moderatorNote} 520 + onChange={(e) => setModeratorNote(e.target.value)} 521 + className="w-3/4" 522 + /> 523 + </div> 524 + )} 416 525 <div className="flex h-px mt-12 bg-slate-200 mb-9" /> 417 526 <FormSectionHeader 418 527 title="Policy"
-113
client/src/webpages/dashboard/mrt/manual_review_job/CustomMrtApiParamsSection.tsx
··· 1 - import { Checkbox } from '@/coop-ui/Checkbox'; 2 - import { Label } from '@/coop-ui/Label'; 3 - import { useGQLActionsWithCustomParamsQuery } from '@/graphql/generated'; 4 - import { filterNullOrUndefined } from '@/utils/collections'; 5 - import { gql } from '@apollo/client'; 6 - import TextArea from 'antd/lib/input/TextArea'; 7 - 8 - gql` 9 - query ActionsWithCustomParams { 10 - myOrg { 11 - actions { 12 - ... on ActionBase { 13 - id 14 - name 15 - } 16 - ... on CustomAction { 17 - id 18 - name 19 - customMrtApiParams { 20 - name 21 - type 22 - displayName 23 - } 24 - } 25 - } 26 - } 27 - } 28 - `; 29 - 30 - export default function CustomMrtApiParamsSection(props: { 31 - selectedActionIds: string[]; 32 - setCustomParamsForAction: ( 33 - actionId: string, 34 - customParams: Record<string, string | boolean>, 35 - ) => void; 36 - }) { 37 - const { selectedActionIds, setCustomParamsForAction } = props; 38 - 39 - const { data } = useGQLActionsWithCustomParamsQuery(); 40 - const actions = data?.myOrg?.actions; 41 - if (!actions) { 42 - return null; 43 - } 44 - const actionsWithCustomParams = actions.filter( 45 - (action) => 46 - action.__typename === 'CustomAction' && 47 - action.customMrtApiParams !== undefined && 48 - action.customMrtApiParams.length > 0, 49 - ); 50 - 51 - return ( 52 - <div className="flex flex-col mt-2 mb-4 gap-1"> 53 - {actionsWithCustomParams.map((action) => { 54 - if ( 55 - !selectedActionIds.includes(action.id) || 56 - !('customMrtApiParams' in action) 57 - ) { 58 - return null; 59 - } 60 - const customParams = filterNullOrUndefined(action.customMrtApiParams); 61 - return customParams.map((actionParam) => { 62 - if (actionParam.type === 'BOOLEAN') { 63 - return ( 64 - <div 65 - key={action.id + actionParam.displayName} 66 - className="flex items-center gap-2" 67 - > 68 - <Checkbox 69 - id={`flag-checkbox-${action.id}-${actionParam.name}`} 70 - onCheckedChange={(checked) => 71 - setCustomParamsForAction(action.id, { 72 - [actionParam.name]: checked, 73 - }) 74 - } 75 - /> 76 - <Label htmlFor={`flag-checkbox-${action.id}`}> 77 - {`"${action.name}" ${actionParam.displayName}`} 78 - </Label> 79 - </div> 80 - ); 81 - } else if (actionParam.type === 'STRING') { 82 - return ( 83 - <div 84 - key={action.id + actionParam.displayName} 85 - className="flex flex-col mb-4" 86 - > 87 - <Label 88 - className="self-start my-2 font-bold" 89 - htmlFor={`text-area-${action.id}-${actionParam.name}`} 90 - > 91 - {`"${action.name}" ${actionParam.displayName}`} 92 - </Label> 93 - <TextArea 94 - id={`text-area-${action.id}-${actionParam.name}`} 95 - className="rounded-md" 96 - placeholder="" 97 - rows={2} 98 - onChange={(e) => 99 - setCustomParamsForAction(action.id, { 100 - [actionParam.name]: e.target.value, 101 - }) 102 - } 103 - /> 104 - </div> 105 - ); 106 - } else { 107 - return null; 108 - } 109 - }); 110 - })} 111 - </div> 112 - ); 113 - }
+272 -138
client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx
··· 3 3 import { __throw } from '@/utils/misc'; 4 4 import { isNonEmptyString } from '@/utils/string'; 5 5 import { multilevelListFromFlatList } from '@/utils/tree'; 6 - import { DownOutlined, LoadingOutlined } from '@ant-design/icons'; 6 + import { 7 + DownOutlined, 8 + EditOutlined, 9 + LoadingOutlined, 10 + } from '@ant-design/icons'; 7 11 import { gql } from '@apollo/client'; 8 12 import { Button, Dropdown, Input, Select, Tooltip } from 'antd'; 9 13 import { useCallback, useContext, useEffect, useRef, useState } from 'react'; 10 14 import { Helmet } from 'react-helmet-async'; 11 15 import { useNavigate, useParams } from 'react-router-dom'; 12 16 17 + import ActionParametersModal, { 18 + defaultValuesForParameters, 19 + } from '@/components/ActionParametersModal'; 20 + import { type ActionParameterValues } from '@/components/ActionParameterInputs'; 13 21 import ComponentLoading from '../../../../components/common/ComponentLoading'; 14 22 import CopyTextComponent from '../../../../components/common/CopyTextComponent'; 15 23 import CoopModal from '../../components/CoopModal'; ··· 35 43 useGQLManualReviewJobInfoQuery, 36 44 useGQLReleaseJobLockMutation, 37 45 useGQLSubmitManualReviewDecisionMutation, 46 + type GQLActionParameter, 38 47 type GQLThreadManualReviewJobPayload, 39 48 type GQLUserItem, 40 49 } from '../../../../graphql/generated'; ··· 44 53 import HTMLRenderer from '../../policies/HTMLRenderer'; 45 54 import { JOB_FRAGMENT } from './jobFragment'; 46 55 import { ITEM_TYPE_FRAGMENT } from '../../rules/rule_form/RuleForm'; 47 - import CustomMrtApiParamsSection from './CustomMrtApiParamsSection'; 48 56 import ManualReviewJobDequeueErrorComponent from './ManualReviewJobDequeueErrorComponent'; 49 57 import MergedReportsComponent from './MergedReportsComponent'; 50 58 import ReportInfoComponent from './ReportInfoComponent'; ··· 58 66 } from './v2/ManualReviewJobRelatedActionsStore'; 59 67 import NCMECReviewUser from './v2/ncmec/NCMECReviewUser'; 60 68 import ManualReviewJobEnqueuedRelatedActions from './v2/related_actions/ManualReviewJobEnqueuedRelatedActions'; 69 + import { useEnqueueActionGate } from './v2/useEnqueueActionGate'; 61 70 import ManualReviewJobListOfThreadsComponent from './v2/threads/ManualReviewJobListOfThreadsComponent'; 62 71 import ManualReviewJobPrimaryUserComponent from './v2/user/ManualReviewJobPrimaryUserComponent'; 63 72 64 73 const { Option } = Select; 65 74 const { TextArea } = Input; 66 75 76 + // Narrows the GraphQL union of action types to "this one declares parameter 77 + // inputs". Only `CustomAction` carries `parameters`; everything else is 78 + // treated as no-parameter. 79 + function actionHasParameters(action: { __typename?: string } | undefined): boolean { 80 + if (!action || !('parameters' in action)) return false; 81 + const params = (action as { parameters?: readonly unknown[] | null }) 82 + .parameters; 83 + return (params?.length ?? 0) > 0; 84 + } 85 + 67 86 gql` 68 87 ${JOB_FRAGMENT} 69 88 ${ITEM_TYPE_FRAGMENT} ··· 102 121 name 103 122 } 104 123 } 105 - customMrtApiParams { 124 + parameters { 106 125 name 107 - type 108 126 displayName 127 + description 128 + type 129 + required 130 + options { 131 + value 132 + label 133 + } 134 + min 135 + max 136 + maxLength 137 + defaultValue 109 138 } 110 139 } 111 140 } ··· 228 257 action: CustomAction; 229 258 target: { identifier: ManualReviewJobItemIdentifier; displayName: string }; 230 259 policies: { id: string; name: string }[]; 231 - customMrtApiParamDecisionPayload?: Record<string, string | boolean>; 260 + // Widened from `string | boolean` to `unknown` so this map can carry the 261 + // full set of parameter types now supported (NUMBER, SELECT, MULTISELECT) 262 + // alongside the original STRING/BOOLEAN. Values are JSON-serializable; the 263 + // server validates them against each action's spec at submit time. 264 + customMrtApiParamDecisionPayload?: Record<string, unknown>; 232 265 }; 233 266 234 267 export type ManualReviewJobAction = { ··· 383 416 } 384 417 } 385 418 }, [jobId, data, loading, navigate, queueId, closedJob]); 386 - const selectedActionsForCustomActionParamsInput = filterNullOrUndefined( 387 - selectedPrimaryActions.map((it) => 388 - 'id' in it.action ? it.action.id : undefined, 389 - ), 390 - ); 391 - const setCustomParamsForActionCallback = ( 392 - actionId: string, 393 - customParams: Record<string, string | boolean>, 394 - ) => { 395 - const updatedAction = selectedPrimaryActions.find( 396 - (action) => !('type' in action.action) && action.action.id === actionId, 397 - ); 398 - if (updatedAction) { 399 - updatedAction.customMrtApiParamDecisionPayload = { 400 - ...updatedAction.customMrtApiParamDecisionPayload, 401 - ...customParams, 419 + // Modal-driven entry of action parameters. Opening the modal in `create` 420 + // mode happens *before* the action is added to `selectedPrimaryActions`, 421 + // so cancelling leaves the picker exactly as it was. `edit` mode opens 422 + // the modal pre-filled with the values already saved on a selected action, 423 + // and Save replaces that single action's payload. 424 + type ParamsModalState = 425 + | { open: false } 426 + | { 427 + open: true; 428 + mode: 'create'; 429 + actionId: string; 430 + actionName: string; 431 + parameters: ReadonlyArray<GQLActionParameter>; 432 + initialValues: ActionParameterValues; 433 + // Snapshot of the click context so we can mirror the existing 434 + // "deselect Ignore on add" branch without re-deriving it. 435 + target: ManualReviewJobEnqueuedPrimaryActionData['target']; 436 + } 437 + | { 438 + open: true; 439 + mode: 'edit'; 440 + actionId: string; 441 + actionName: string; 442 + parameters: ReadonlyArray<GQLActionParameter>; 443 + initialValues: ActionParameterValues; 402 444 }; 403 - setSelectedPrimaryActions([ 404 - ...selectedPrimaryActions.filter( 405 - (action) => 'type' in action.action || action.action.id !== actionId, 406 - ), 407 - updatedAction, 408 - ]); 409 - } 410 - }; 445 + const [paramsModal, setParamsModal] = useState<ParamsModalState>({ 446 + open: false, 447 + }); 411 448 412 449 const goBackToQueuesPage = () => navigate('/dashboard/manual_review/queues'); 413 450 const hideModal = () => setModalInfo({ ...modalInfo, visible: false }); ··· 575 612 [data?.myOrg], 576 613 ); 577 614 615 + // Gate any related-action enqueues that involve a parameterized action 616 + // behind the shared `ActionParametersModal`. Items without parameters (or 617 + // already carrying a `customMrtApiParamDecisionPayload` from another flow) 618 + // pass through unchanged. The modal element is rendered once below. 619 + // Hook is declared up here (rather than next to the v2 component props) 620 + // because `ManualReviewJobReviewImpl` short-circuits with `throw` further 621 + // down, and rules-of-hooks forbid placing hooks after a conditional throw. 622 + const enqueueGate = useEnqueueActionGate({ 623 + allActions: data?.myOrg?.actions ?? [], 624 + onEnqueueActions: (actions) => 625 + setSelectedRelatedActions( 626 + recomputeSelectedRelatedActions(actions, selectedRelatedActions), 627 + ), 628 + }); 629 + 578 630 const job = closedJob 579 631 ? closedJob 580 632 : jobData ··· 806 858 ...builtInMoveAction, 807 859 ]; 808 860 861 + // Builds the standard target descriptor for the reported item. Hoisted so 862 + // both the direct-add path and the modal Save handler use the same shape. 863 + const reportedItemTarget = (): ManualReviewJobEnqueuedPrimaryActionData['target'] => ({ 864 + identifier: { 865 + itemId: reportedItem.id, 866 + itemTypeId: reportedItem.type.id, 867 + }, 868 + displayName: 869 + getFieldValueForRole<GQLSchemaFieldRoles, keyof GQLSchemaFieldRoles>( 870 + reportedItem, 871 + 'displayName', 872 + ) ?? reportedItem.id, 873 + }); 874 + 875 + // Returns the parameter spec for a CustomAction by id, or `[]` for 876 + // built-ins / actions without a spec. Looks up against `decisionActions` 877 + // since that's the list rendered in the picker. 878 + const getActionParameters = ( 879 + actionId: string, 880 + ): ReadonlyArray<GQLActionParameter> => { 881 + const found = decisionActions.find( 882 + (a) => !('type' in a) && a.id === actionId, 883 + ); 884 + if (!found || 'type' in found || !('parameters' in found)) return []; 885 + return (found.parameters ?? []) as ReadonlyArray<GQLActionParameter>; 886 + }; 887 + 888 + // Adds an action to `selectedPrimaryActions` while preserving the existing 889 + // mutual-exclusion rules (Ignore/Accept/Reject collapse the selection; 890 + // selecting any other action clears Ignore). Pulled out of the inline 891 + // click handler so the modal Save handler can reuse the exact same logic. 892 + const addPrimaryAction = ( 893 + action: (typeof decisionActions)[number], 894 + customMrtApiParamDecisionPayload?: Record<string, unknown>, 895 + ) => { 896 + const newAction: ManualReviewJobEnqueuedPrimaryActionData = { 897 + action: action as ManualReviewJobEnqueuedPrimaryActionData['action'], 898 + target: reportedItemTarget(), 899 + policies: selectedPrimaryPolicies, 900 + ...(customMrtApiParamDecisionPayload 901 + ? { customMrtApiParamDecisionPayload } 902 + : {}), 903 + }; 904 + if ( 905 + 'type' in action && 906 + (action.type === 'IGNORE' || 907 + action.type === 'ACCEPT_APPEAL' || 908 + action.type === 'REJECT_APPEAL') 909 + ) { 910 + setSelectedPrimaryActions([newAction]); 911 + setSelectedPrimaryPolicies([]); 912 + } else { 913 + setSelectedPrimaryActions([ 914 + ...selectedPrimaryActions.filter( 915 + (a) => !('type' in a.action && a.action.type === 'IGNORE'), 916 + ), 917 + newAction, 918 + ]); 919 + } 920 + }; 921 + 922 + // Replaces a single selected action's parameter payload (used by the 923 + // modal Save in `edit` mode). 924 + const updateSelectedActionParams = ( 925 + actionId: string, 926 + customMrtApiParamDecisionPayload: Record<string, unknown>, 927 + ) => { 928 + setSelectedPrimaryActions( 929 + selectedPrimaryActions.map((a) => 930 + !('type' in a.action) && a.action.id === actionId 931 + ? { ...a, customMrtApiParamDecisionPayload } 932 + : a, 933 + ), 934 + ); 935 + }; 936 + 809 937 const actionList = ( 810 938 <div 811 939 className="sticky flex flex-col border border-gray-200 border-solid rounded-md shrink-0" ··· 921 1049 action.type === BuiltInActionType.EnqueueToNcmec && 922 1050 !org.hasNCMECReportingEnabled; 923 1051 1052 + // Show a pencil only for selected CustomActions whose spec has at 1053 + // least one parameter — that's exactly when re-editing has any 1054 + // effect. Click stops propagation so the row's deselect-on-click 1055 + // doesn't fire underneath. 1056 + const showEditPencil = 1057 + selected && 1058 + !('type' in action) && 1059 + getActionParameters(action.id).length > 0; 1060 + 924 1061 const actionDiv = ( 925 1062 <div 926 - className={`self-stretch text-start font-semibold p-3 ${ 1063 + className={`self-stretch flex flex-row items-center justify-between text-start font-semibold p-3 ${ 927 1064 isNcmecDisabled 928 1065 ? 'cursor-not-allowed text-gray-400 bg-gray-50' 929 1066 : selected ··· 936 1073 return; 937 1074 } 938 1075 if (selected) { 939 - // If the action is a built-in action, then nothing else can 940 - // be selected anyway, so we should deselect everything 1076 + // Built-in actions are mutually exclusive with everything, 1077 + // so deselecting Ignore clears the entire selection. 941 1078 // TODO: Create a better definition for built-in actions, and 942 - // how they correlate with custom actions (since we 943 - // can't necessarily depend on 'type' always being a key). 1079 + // how they correlate with custom actions (since we can't 1080 + // necessarily depend on 'type' always being a key). 944 1081 if ('type' in action && action.type === 'IGNORE') { 945 1082 setSelectedPrimaryActions([]); 946 1083 } else if ('type' in action) { ··· 964 1101 ), 965 1102 ); 966 1103 } 967 - } else { 968 - const newAction = { 969 - action, 970 - target: { 971 - identifier: { 972 - itemId: reportedItem.id, 973 - itemTypeId: reportedItem.type.id, 974 - }, 975 - displayName: 976 - getFieldValueForRole< 977 - GQLSchemaFieldRoles, 978 - keyof GQLSchemaFieldRoles 979 - >(reportedItem, 'displayName') ?? reportedItem.id, 980 - }, 981 - policies: selectedPrimaryPolicies, 982 - }; 983 - 984 - // If the action is Ignore, or a built in appeal action we should deselect 985 - // every other selected action 986 - if ( 987 - 'type' in action && 988 - (action.type === 'IGNORE' || 989 - action.type === 'ACCEPT_APPEAL' || 990 - action.type === 'REJECT_APPEAL') 991 - ) { 992 - setSelectedPrimaryActions([newAction]); 993 - setSelectedPrimaryPolicies([]); 994 - } else { 995 - // Deselect Ignore if a user action is 996 - // selected 997 - setSelectedPrimaryActions([ 998 - ...selectedPrimaryActions.filter( 999 - (action) => 1000 - !( 1001 - 'type' in action.action && 1002 - action.action.type === 'IGNORE' 1003 - ), 1004 - ), 1005 - { 1006 - ...newAction, 1007 - // for user actions, we should set the default custom 1008 - // mrt params if they exist 1009 - ...('id' in action && 1010 - action.__typename === 'CustomAction' && 1011 - action.customMrtApiParams 1012 - ? { 1013 - customMrtApiParamDecisionPayload: 1014 - filterNullOrUndefined( 1015 - action.customMrtApiParams, 1016 - ).reduce( 1017 - (acc, param) => ({ 1018 - ...acc, 1019 - [param.name]: 1020 - param.type === 'BOOLEAN' 1021 - ? false 1022 - : param.type === 'STRING' 1023 - ? '' 1024 - : undefined, 1025 - }), 1026 - {}, 1027 - ), 1028 - } 1029 - : {}), 1030 - }, 1031 - ]); 1104 + return; 1105 + } 1106 + // Not selected — for parameterized CustomActions, gate the 1107 + // selection behind the parameter modal. The action is only 1108 + // committed on Save, so cancelling leaves the picker 1109 + // unchanged. 1110 + if (!('type' in action)) { 1111 + const parameters = getActionParameters(action.id); 1112 + if (parameters.length > 0) { 1113 + setParamsModal({ 1114 + open: true, 1115 + mode: 'create', 1116 + actionId: action.id, 1117 + actionName: action.name, 1118 + parameters, 1119 + initialValues: defaultValuesForParameters(parameters), 1120 + target: reportedItemTarget(), 1121 + }); 1122 + return; 1032 1123 } 1033 1124 } 1125 + addPrimaryAction(action); 1034 1126 }} 1035 1127 > 1036 - {label} 1128 + <span>{label}</span> 1129 + {showEditPencil && ( 1130 + <Tooltip title="Edit details"> 1131 + <span 1132 + className="ml-2 text-sky-600 hover:text-sky-700" 1133 + onClick={(e) => { 1134 + e.stopPropagation(); 1135 + if ('type' in action) return; 1136 + const parameters = getActionParameters(action.id); 1137 + const current = selectedPrimaryActions.find( 1138 + (a) => 1139 + !('type' in a.action) && a.action.id === action.id, 1140 + ); 1141 + setParamsModal({ 1142 + open: true, 1143 + mode: 'edit', 1144 + actionId: action.id, 1145 + actionName: action.name, 1146 + parameters, 1147 + initialValues: 1148 + (current?.customMrtApiParamDecisionPayload as 1149 + | ActionParameterValues 1150 + | undefined) ?? {}, 1151 + }); 1152 + }} 1153 + > 1154 + <EditOutlined /> 1155 + </span> 1156 + </Tooltip> 1157 + )} 1037 1158 </div> 1038 1159 ); 1039 1160 return isNcmecDisabled ? ( ··· 1201 1322 allItemTypes={org.itemTypes as GQLItemType[]} 1202 1323 relatedActions={selectedRelatedActions} 1203 1324 allPolicies={org.policies} 1204 - onEnqueueActions={(actions) => 1205 - setSelectedRelatedActions( 1206 - recomputeSelectedRelatedActions(actions, selectedRelatedActions), 1207 - ) 1208 - } 1325 + onEnqueueActions={enqueueGate.enqueueActions} 1209 1326 parentRef={mrtParentComponentRef} 1210 1327 reportedUserRef={reportedUserRef} 1211 1328 unblurAllMedia={unblurAllMedia} ··· 1231 1348 allItemTypes={org.itemTypes as GQLItemType[]} 1232 1349 relatedActions={selectedRelatedActions} 1233 1350 allPolicies={org.policies} 1234 - onEnqueueActions={(actions) => 1235 - setSelectedRelatedActions( 1236 - recomputeSelectedRelatedActions(actions, selectedRelatedActions), 1237 - ) 1238 - } 1351 + onEnqueueActions={enqueueGate.enqueueActions} 1239 1352 reportedUserRef={reportedUserRef} 1240 1353 unblurAllMedia={unblurAllMedia} 1241 1354 isActionable={!closedJob} ··· 1258 1371 : (payload as GQLContentAppealManualReviewJobPayload) 1259 1372 } 1260 1373 allActions={closedJob ? [] : filteredActions} 1261 - onEnqueueActions={(actions) => 1262 - setSelectedRelatedActions( 1263 - recomputeSelectedRelatedActions( 1264 - actions, 1265 - selectedRelatedActions, 1266 - ), 1267 - ) 1268 - } 1374 + onEnqueueActions={enqueueGate.enqueueActions} 1269 1375 allPolicies={org.policies} 1270 1376 allItemTypes={org.itemTypes as GQLItemType[]} 1271 1377 relatedActions={selectedRelatedActions} ··· 1290 1396 : (payload as GQLThreadAppealManualReviewJobPayload) 1291 1397 } 1292 1398 allActions={closedJob ? [] : filteredActions} 1293 - onEnqueueActions={(actions) => 1294 - setSelectedRelatedActions( 1295 - recomputeSelectedRelatedActions( 1296 - actions, 1297 - selectedRelatedActions, 1298 - ), 1299 - ) 1300 - } 1399 + onEnqueueActions={enqueueGate.enqueueActions} 1301 1400 allPolicies={org.policies} 1302 1401 allItemTypes={org.itemTypes as GQLItemType[]} 1303 1402 relatedActions={selectedRelatedActions} ··· 1323 1422 allPolicies={org.policies} 1324 1423 relatedActions={selectedRelatedActions} 1325 1424 reportedUserRef={reportedUserRef} 1326 - onEnqueueActions={(actions) => 1327 - setSelectedRelatedActions( 1328 - recomputeSelectedRelatedActions( 1329 - actions, 1330 - selectedRelatedActions, 1331 - ), 1332 - ) 1333 - } 1425 + onEnqueueActions={enqueueGate.enqueueActions} 1334 1426 requirePolicySelectionToEnqueueAction={ 1335 1427 org.requiresPolicyForDecisionsInMrt 1336 1428 } ··· 1458 1550 action.target.identifier.itemId, 1459 1551 }, 1460 1552 policyNames: action.policies.map((policy) => policy.name), 1553 + hasParameters: actionHasParameters( 1554 + org.actions.find((a) => a.id === action.action.id), 1555 + ), 1461 1556 }))} 1462 1557 onRemoveAction={(action) => 1463 1558 setSelectedRelatedActions([ ··· 1472 1567 ), 1473 1568 ]) 1474 1569 } 1475 - /> 1476 - <CustomMrtApiParamsSection 1477 - selectedActionIds={selectedActionsForCustomActionParamsInput} 1478 - setCustomParamsForAction={setCustomParamsForActionCallback} 1570 + onEditAction={(action) => { 1571 + const entry = selectedRelatedActions.find( 1572 + (a) => 1573 + a.target.identifier.itemId === action.target.itemId && 1574 + a.target.identifier.itemTypeId === 1575 + action.target.itemTypeId && 1576 + a.action.id === action.id, 1577 + ); 1578 + if (entry) { 1579 + enqueueGate.editParameters( 1580 + entry, 1581 + selectedRelatedActions, 1582 + setSelectedRelatedActions, 1583 + ); 1584 + } 1585 + }} 1479 1586 /> 1587 + {paramsModal.open && ( 1588 + <ActionParametersModal 1589 + open={paramsModal.open} 1590 + actionName={paramsModal.actionName} 1591 + parameters={paramsModal.parameters} 1592 + initialValues={paramsModal.initialValues} 1593 + mode={paramsModal.mode} 1594 + onCancel={() => setParamsModal({ open: false })} 1595 + onSave={(values) => { 1596 + if (paramsModal.mode === 'create') { 1597 + const action = decisionActions.find( 1598 + (a) => 1599 + !('type' in a) && a.id === paramsModal.actionId, 1600 + ); 1601 + if (action) { 1602 + addPrimaryAction(action, { ...values }); 1603 + } 1604 + } else { 1605 + updateSelectedActionParams(paramsModal.actionId, { 1606 + ...values, 1607 + }); 1608 + } 1609 + setParamsModal({ open: false }); 1610 + }} 1611 + /> 1612 + )} 1613 + {enqueueGate.modalElement} 1480 1614 <div 1481 1615 className={`flex w-full justify-center items-center rounded-md text-sm shadow-none drop-shadow-none p-2 font-semibold ${ 1482 1616 canBeSubmitted
+26 -2
client/src/webpages/dashboard/mrt/manual_review_job/v2/related_actions/ManualReviewJobEnqueuedRelatedActionEntry.tsx
··· 1 + import Pencil from '@/icons/lni/Education/pencil.svg?react'; 1 2 import UserAlt4 from '@/icons/lni/User/user-alt-4.svg?react'; 2 3 import { ItemIdentifier } from '@roostorg/types'; 4 + import { Tooltip } from 'antd'; 3 5 4 6 import CloseButton from '@/components/common/CloseButton'; 5 7 ··· 17 19 iconUrl?: string; 18 20 policyNames: readonly string[]; 19 21 onRemove: () => void; 22 + // Optional edit affordance for parameterized actions. Hidden when 23 + // `undefined` so non-parameterized entries stay unchanged. 24 + onEditParameters?: () => void; 20 25 }) { 21 - const { label, sublabel, itemIdentifier, iconUrl, policyNames, onRemove } = 22 - props; 26 + const { 27 + label, 28 + sublabel, 29 + itemIdentifier, 30 + iconUrl, 31 + policyNames, 32 + onRemove, 33 + onEditParameters, 34 + } = props; 23 35 24 36 return ( 25 37 <div className="flex flex-col items-start max-w-[240px]"> ··· 32 44 fallbackComponent={<UserAlt4 className="p-3 fill-slate-500 w-11" />} 33 45 labelTruncationType="wrap" 34 46 /> 47 + {onEditParameters && ( 48 + <Tooltip title="Edit details"> 49 + <button 50 + type="button" 51 + aria-label="Edit action details" 52 + className="flex items-center justify-center w-5 h-5 text-slate-400 hover:text-slate-700 cursor-pointer bg-transparent border-none p-0" 53 + onClick={onEditParameters} 54 + > 55 + <Pencil className="w-3 h-3 fill-current" /> 56 + </button> 57 + </Tooltip> 58 + )} 35 59 <CloseButton onClose={onRemove} /> 36 60 </div> 37 61 {policyNames.length > 0 ? (
+15 -1
client/src/webpages/dashboard/mrt/manual_review_job/v2/related_actions/ManualReviewJobEnqueuedRelatedActions.tsx
··· 20 20 displayName: string; 21 21 }; 22 22 policyNames: readonly string[]; 23 + // Marks parameterized entries so we render the Edit affordance only for 24 + // those; non-parameterized actions stay visually unchanged. 25 + hasParameters?: boolean; 23 26 }; 24 27 25 28 export default function ManualReviewJobEnqueuedRelatedActions(props: { 26 29 actionsData: Action[]; 27 30 onRemoveAction: (action: Action) => void; 31 + onEditAction?: (action: Action) => void; 28 32 }) { 29 - const { actionsData: actions, onRemoveAction } = props; 33 + const { actionsData: actions, onRemoveAction, onEditAction } = props; 30 34 31 35 // Group actions by action Id and associate with list of targets on which that 32 36 // action will be performed ··· 40 44 targetsWithPolicies: actionsById[actionId].map((action) => ({ 41 45 target: action.target, 42 46 policyNames: action.policyNames, 47 + hasParameters: action.hasParameters ?? false, 43 48 })), 44 49 })); 45 50 ··· 78 83 ...groupedAction.action, 79 84 ...targetWithPolicies, 80 85 }) 86 + } 87 + onEditParameters={ 88 + targetWithPolicies.hasParameters && onEditAction 89 + ? () => 90 + onEditAction({ 91 + ...groupedAction.action, 92 + ...targetWithPolicies, 93 + }) 94 + : undefined 81 95 } 82 96 /> 83 97 ))}
+189
client/src/webpages/dashboard/mrt/manual_review_job/v2/useEnqueueActionGate.tsx
··· 1 + import ActionParametersModal, { 2 + defaultValuesForParameters, 3 + } from '@/components/ActionParametersModal'; 4 + import { 5 + type ActionParameterValues, 6 + } from '@/components/ActionParameterInputs'; 7 + import { type GQLActionParameter } from '@/graphql/generated'; 8 + import groupBy from 'lodash/groupBy'; 9 + import { type ReactNode, useCallback, useState } from 'react'; 10 + 11 + import { type ManualReviewJobEnqueuedActionData } from '../ManualReviewJobReview'; 12 + 13 + type ActionWithParameters = { 14 + id: string; 15 + name: string; 16 + parameters?: ReadonlyArray<GQLActionParameter> | null; 17 + }; 18 + 19 + type PendingGroup = { 20 + mode: 'create'; 21 + actionId: string; 22 + actionName: string; 23 + parameters: ReadonlyArray<GQLActionParameter>; 24 + items: ManualReviewJobEnqueuedActionData[]; 25 + initialValues: ActionParameterValues; 26 + }; 27 + 28 + type EditTarget = { 29 + mode: 'edit'; 30 + actionId: string; 31 + actionName: string; 32 + parameters: ReadonlyArray<GQLActionParameter>; 33 + initialValues: ActionParameterValues; 34 + // Predicate identifying the entry whose payload should be updated. 35 + match: (entry: ManualReviewJobEnqueuedActionData) => boolean; 36 + }; 37 + 38 + type QueueEntry = PendingGroup | EditTarget; 39 + 40 + /** 41 + * Wraps an `onEnqueueActions` callback so that any incoming items whose 42 + * action declares `parameters` are gated behind the shared 43 + * `ActionParametersModal`. Items for an action without parameters (or that 44 + * already carry a `customMrtApiParamDecisionPayload` from another flow) pass 45 + * through unchanged. 46 + * 47 + * Multiple targets enqueued for the same action in one batch share a single 48 + * modal prompt; the saved values are applied to every target in that group. 49 + * 50 + * Also exposes `editParameters(entry, allEnqueued, replaceAll)` for 51 + * re-opening the modal against an already-enqueued entry — the saved values 52 + * replace just that entry's payload via `replaceAll`. 53 + * 54 + * Returns the wrapped callback, the editor opener, and a `modalElement` the 55 + * caller is responsible for rendering once. 56 + */ 57 + export function useEnqueueActionGate(args: { 58 + allActions: ReadonlyArray<ActionWithParameters>; 59 + onEnqueueActions: (actions: ManualReviewJobEnqueuedActionData[]) => void; 60 + }): { 61 + enqueueActions: (actions: ManualReviewJobEnqueuedActionData[]) => void; 62 + editParameters: ( 63 + entry: ManualReviewJobEnqueuedActionData, 64 + allEnqueued: ReadonlyArray<ManualReviewJobEnqueuedActionData>, 65 + replaceAll: (next: ManualReviewJobEnqueuedActionData[]) => void, 66 + ) => void; 67 + modalElement: ReactNode; 68 + } { 69 + const { allActions, onEnqueueActions } = args; 70 + const [queue, setQueue] = useState<ReadonlyArray<QueueEntry>>([]); 71 + const [editCommit, setEditCommit] = useState< 72 + ((values: ActionParameterValues) => void) | null 73 + >(null); 74 + 75 + const enqueueActions = useCallback( 76 + (actions: ManualReviewJobEnqueuedActionData[]) => { 77 + const passthrough: ManualReviewJobEnqueuedActionData[] = []; 78 + const newGroups: PendingGroup[] = []; 79 + 80 + const grouped = groupBy(actions, (a) => a.action.id); 81 + for (const [actionId, items] of Object.entries(grouped)) { 82 + const actionMeta = allActions.find((a) => a.id === actionId); 83 + const parameters = actionMeta?.parameters ?? []; 84 + const needsPrompt = (it: ManualReviewJobEnqueuedActionData) => 85 + parameters.length > 0 && it.customMrtApiParamDecisionPayload == null; 86 + const itemsNeedingPrompt = items.filter(needsPrompt); 87 + const itemsReady = items.filter((it) => !needsPrompt(it)); 88 + 89 + if (itemsReady.length > 0) passthrough.push(...itemsReady); 90 + if (itemsNeedingPrompt.length > 0) { 91 + newGroups.push({ 92 + mode: 'create', 93 + actionId, 94 + actionName: actionMeta?.name ?? actionId, 95 + parameters, 96 + items: itemsNeedingPrompt, 97 + initialValues: defaultValuesForParameters(parameters), 98 + }); 99 + } 100 + } 101 + 102 + if (passthrough.length > 0) onEnqueueActions(passthrough); 103 + if (newGroups.length > 0) { 104 + setQueue((prev) => [...prev, ...newGroups]); 105 + } 106 + }, 107 + [allActions, onEnqueueActions], 108 + ); 109 + 110 + const editParameters = useCallback( 111 + ( 112 + entry: ManualReviewJobEnqueuedActionData, 113 + allEnqueued: ReadonlyArray<ManualReviewJobEnqueuedActionData>, 114 + replaceAll: (next: ManualReviewJobEnqueuedActionData[]) => void, 115 + ) => { 116 + const actionMeta = allActions.find((a) => a.id === entry.action.id); 117 + const parameters = actionMeta?.parameters ?? []; 118 + if (parameters.length === 0) return; 119 + const initialValues = 120 + (entry.customMrtApiParamDecisionPayload as 121 + | ActionParameterValues 122 + | undefined) ?? defaultValuesForParameters(parameters); 123 + const match = (it: ManualReviewJobEnqueuedActionData) => 124 + it.action.id === entry.action.id && 125 + it.target.identifier.itemId === entry.target.identifier.itemId && 126 + it.target.identifier.itemTypeId === entry.target.identifier.itemTypeId; 127 + // Snapshot `allEnqueued` so the commit reads from the moment the user 128 + // opened the editor; the parent's state may move on while the modal is 129 + // up. 130 + const commit = (values: ActionParameterValues) => { 131 + replaceAll( 132 + allEnqueued.map((it) => 133 + match(it) 134 + ? { 135 + ...it, 136 + customMrtApiParamDecisionPayload: { ...values }, 137 + } 138 + : it, 139 + ), 140 + ); 141 + }; 142 + setEditCommit(() => commit); 143 + setQueue((prev) => [ 144 + ...prev, 145 + { 146 + mode: 'edit', 147 + actionId: entry.action.id, 148 + actionName: actionMeta?.name ?? entry.action.name, 149 + parameters, 150 + initialValues, 151 + match, 152 + }, 153 + ]); 154 + }, 155 + [allActions], 156 + ); 157 + 158 + const advance = useCallback(() => { 159 + setQueue((prev) => prev.slice(1)); 160 + setEditCommit(null); 161 + }, []); 162 + 163 + const current = queue[0]; 164 + const modalElement = current ? ( 165 + <ActionParametersModal 166 + open 167 + mode={current.mode} 168 + actionName={current.actionName} 169 + parameters={current.parameters} 170 + initialValues={current.initialValues} 171 + onCancel={advance} 172 + onSave={(values) => { 173 + if (current.mode === 'create') { 174 + onEnqueueActions( 175 + current.items.map((it) => ({ 176 + ...it, 177 + customMrtApiParamDecisionPayload: { ...values }, 178 + })), 179 + ); 180 + } else if (editCommit) { 181 + editCommit(values); 182 + } 183 + advance(); 184 + }} 185 + /> 186 + ) : null; 187 + 188 + return { enqueueActions, editParameters, modalElement }; 189 + }
+24
db/src/scripts/clickhouse/2026.05.02T00.07.03.add-action-execution-parameters-and-note.sql
··· 1 + -- Adds two columns to `analytics.ACTION_EXECUTIONS` to capture the moderator- 2 + -- supplied runtime context for each action execution: 3 + -- 4 + -- parameters JSON blob of validated parameter values that were passed to 5 + -- the action's webhook (matches the `name -> value` map on 6 + -- `actions.custom_mrt_api_params`). Empty `'{}'` when the 7 + -- action takes no parameters or none were supplied. Stored as 8 + -- `String` (canonical JSON) to match the existing pattern used 9 + -- by `rules`, `policies`, etc. on this table. 10 + -- 11 + -- actor_note Optional free-text note authored by the moderator 12 + -- explaining why the action was taken. Capped at 5000 chars 13 + -- by the API layer; nullable in the column for back-compat 14 + -- with existing rows. 15 + -- 16 + -- Both default to safe values so existing rows remain queryable without a 17 + -- backfill. The default-on-insert behavior also means callers that don't yet 18 + -- send these fields (older code paths) keep working unchanged. 19 + 20 + ALTER TABLE analytics.ACTION_EXECUTIONS 21 + ADD COLUMN IF NOT EXISTS parameters String DEFAULT '{}'; 22 + 23 + ALTER TABLE analytics.ACTION_EXECUTIONS 24 + ADD COLUMN IF NOT EXISTS actor_note Nullable(String);
+182
server/graphql/datasources/ActionApi.test.ts
··· 1 + import ActionAPI from './ActionApi.js'; 2 + 3 + type ActionAPICtor = new ( 4 + actionPublisher: { publishActions: jest.Mock }, 5 + moderationConfigService: { 6 + getActions: jest.Mock; 7 + getPoliciesByIds: jest.Mock; 8 + }, 9 + tracer: unknown, 10 + itemInvestigationService: { getItemByIdentifier: jest.Mock }, 11 + getItemTypeEventuallyConsistent: jest.Mock, 12 + ) => InstanceType<typeof ActionAPI>; 13 + 14 + function makeApi(overrides?: { 15 + action?: Record<string, unknown>; 16 + }) { 17 + const action = overrides?.action ?? { 18 + id: 'action-1', 19 + orgId: 'org-1', 20 + name: 'Ban User', 21 + actionType: 'CUSTOM_ACTION', 22 + callbackUrl: 'https://example.com', 23 + callbackUrlHeaders: null, 24 + callbackUrlBody: null, 25 + applyUserStrikes: false, 26 + customMrtApiParams: [ 27 + { 28 + name: 'num_days', 29 + displayName: 'Number of days', 30 + type: 'NUMBER', 31 + required: true, 32 + }, 33 + { 34 + name: 'reason', 35 + displayName: 'Reason', 36 + type: 'SELECT', 37 + required: false, 38 + options: [ 39 + { value: 'spam', label: 'Spam' }, 40 + { value: 'abuse', label: 'Abuse' }, 41 + ], 42 + }, 43 + ], 44 + }; 45 + 46 + const publishActions = jest.fn().mockResolvedValue([]); 47 + const getActions = jest.fn().mockResolvedValue([action]); 48 + const getPoliciesByIds = jest.fn().mockResolvedValue([]); 49 + const getItemByIdentifier = jest.fn().mockResolvedValue({ 50 + latestSubmission: { 51 + itemId: 'item-1', 52 + itemType: { id: 'type-1', kind: 'CONTENT', name: 'Social Post' }, 53 + }, 54 + }); 55 + const getItemTypeEventuallyConsistent = jest.fn().mockResolvedValue({ 56 + id: 'type-1', 57 + kind: 'CONTENT', 58 + name: 'Social Post', 59 + }); 60 + 61 + const api = new (ActionAPI as unknown as ActionAPICtor)( 62 + { publishActions }, 63 + { getActions, getPoliciesByIds }, 64 + {}, 65 + { getItemByIdentifier }, 66 + getItemTypeEventuallyConsistent, 67 + ); 68 + 69 + return { 70 + api, 71 + publishActions, 72 + getActions, 73 + }; 74 + } 75 + 76 + const baseCallArgs = { 77 + itemIds: ['item-1'], 78 + actionIds: ['action-1'], 79 + itemTypeId: 'type-1', 80 + policyIds: [], 81 + orgId: 'org-1', 82 + actorId: 'actor-1', 83 + actorEmail: 'mod@example.com', 84 + }; 85 + 86 + describe('ActionAPI.bulkExecuteActions', () => { 87 + it('passes validated parameter values and actorNote through to the publisher', async () => { 88 + const { api, publishActions } = makeApi(); 89 + 90 + await api.bulkExecuteActions({ 91 + ...baseCallArgs, 92 + actionIdToParameters: { 93 + 'action-1': { num_days: 7, reason: 'spam' }, 94 + }, 95 + actorNote: 'Repeat offender', 96 + }); 97 + 98 + expect(publishActions).toHaveBeenCalledTimes(1); 99 + const [triggered, ctx] = publishActions.mock.calls[0]; 100 + expect(triggered[0].customMrtApiParamDecisionPayload).toEqual({ 101 + num_days: 7, 102 + reason: 'spam', 103 + }); 104 + expect(ctx.actorNote).toBe('Repeat offender'); 105 + expect(ctx.actorEmail).toBe('mod@example.com'); 106 + expect(ctx.correlationId).toMatch(/^manual-action-run:/); 107 + }); 108 + 109 + it('rejects missing required parameters before any publish call', async () => { 110 + const { api, publishActions } = makeApi(); 111 + 112 + await expect( 113 + api.bulkExecuteActions({ 114 + ...baseCallArgs, 115 + actionIdToParameters: { 'action-1': {} }, // missing required num_days 116 + }), 117 + ).rejects.toThrow(/num_days/i); 118 + expect(publishActions).not.toHaveBeenCalled(); 119 + }); 120 + 121 + it('rejects unknown parameter keys', async () => { 122 + const { api, publishActions } = makeApi(); 123 + 124 + await expect( 125 + api.bulkExecuteActions({ 126 + ...baseCallArgs, 127 + actionIdToParameters: { 128 + 'action-1': { num_days: 7, bogus_field: 'x' }, 129 + }, 130 + }), 131 + ).rejects.toThrow(/bogus_field|unknown/i); 132 + expect(publishActions).not.toHaveBeenCalled(); 133 + }); 134 + 135 + it('rejects type-mismatched parameter values', async () => { 136 + const { api, publishActions } = makeApi(); 137 + 138 + await expect( 139 + api.bulkExecuteActions({ 140 + ...baseCallArgs, 141 + actionIdToParameters: { 142 + 'action-1': { num_days: 'seven' }, // string into a NUMBER field 143 + }, 144 + }), 145 + ).rejects.toThrow(); 146 + expect(publishActions).not.toHaveBeenCalled(); 147 + }); 148 + 149 + it('rejects an actorNote that exceeds the maximum length', async () => { 150 + const { api, publishActions } = makeApi(); 151 + 152 + await expect( 153 + api.bulkExecuteActions({ 154 + ...baseCallArgs, 155 + actionIdToParameters: { 'action-1': { num_days: 1 } }, 156 + actorNote: 'x'.repeat(5001), 157 + }), 158 + ).rejects.toThrow(/note exceeds maximum length/i); 159 + expect(publishActions).not.toHaveBeenCalled(); 160 + }); 161 + 162 + it('forwards no parameter payload when the action has no spec and no values are supplied', async () => { 163 + const { api, publishActions } = makeApi({ 164 + action: { 165 + id: 'action-1', 166 + orgId: 'org-1', 167 + name: 'Plain Action', 168 + actionType: 'CUSTOM_ACTION', 169 + callbackUrl: 'https://example.com', 170 + callbackUrlHeaders: null, 171 + callbackUrlBody: null, 172 + applyUserStrikes: false, 173 + customMrtApiParams: null, 174 + }, 175 + }); 176 + 177 + await api.bulkExecuteActions(baseCallArgs); 178 + 179 + const [triggered] = publishActions.mock.calls[0]; 180 + expect(triggered[0].customMrtApiParamDecisionPayload).toBeUndefined(); 181 + }); 182 + });
+108 -50
server/graphql/datasources/ActionApi.ts
··· 3 3 import { v1 as uuidv1 } from 'uuid'; 4 4 5 5 import { inject, type Dependencies } from '../../iocContainer/index.js'; 6 + import { 7 + parseStoredParameters, 8 + validateActionParameterValues, 9 + validateActorNote, 10 + type Action, 11 + } from '../../services/moderationConfigService/index.js'; 6 12 import { toCorrelationId } from '../../utils/correlationIds.js'; 7 13 import { makeNotFoundError } from '../../utils/errors.js'; 8 14 import { ··· 56 62 callbackUrlHeaders, 57 63 callbackUrlBody, 58 64 applyUserStrikes, 65 + parameters, 59 66 } = input; 60 67 61 68 return this.moderationConfigService.createAction(orgId, { ··· 67 74 callbackUrlBody: callbackUrlBody ?? null, 68 75 applyUserStrikes: applyUserStrikes ?? undefined, 69 76 itemTypeIds, 77 + parameters: parameters ?? undefined, 70 78 }); 71 79 } 72 80 ··· 80 88 callbackUrlHeaders, 81 89 callbackUrlBody, 82 90 applyUserStrikes, 91 + parameters, 83 92 } = input; 84 93 85 94 return this.moderationConfigService.updateCustomAction(orgId, { ··· 91 100 callbackUrlHeaders, 92 101 callbackUrlBody, 93 102 applyUserStrikes: applyUserStrikes ?? undefined, 103 + parameters: 104 + parameters === undefined ? undefined : (parameters ?? []), 94 105 }, 95 106 itemTypeIds: itemTypeIds ?? undefined, 96 107 }); ··· 112 123 } 113 124 } 114 125 115 - async bulkExecuteActions( 116 - itemIds: readonly string[], 117 - actionIds: readonly string[], 118 - itemTypeId: string, 119 - policyIds: readonly string[], 120 - orgId: string, 121 - actorId: string, 122 - actorEmail: string, 123 - ) { 126 + async bulkExecuteActions(opts: { 127 + itemIds: readonly string[]; 128 + actionIds: readonly string[]; 129 + itemTypeId: string; 130 + policyIds: readonly string[]; 131 + orgId: string; 132 + actorId: string; 133 + actorEmail: string; 134 + /** 135 + * Map of `actionId` -> `{ paramName: value }` carrying moderator-supplied 136 + * runtime parameter values. Validated per-action against each action's 137 + * stored spec; rejects with a 400 if any value is missing/wrong-type. 138 + */ 139 + actionIdToParameters?: Record<string, Record<string, unknown>> | null; 140 + /** 141 + * Optional moderator note. Forwarded to the action's webhook as 142 + * `actorNote` and persisted in the audit log (PR 3). 143 + */ 144 + actorNote?: string | null; 145 + }) { 146 + const { 147 + itemIds, 148 + actionIds, 149 + itemTypeId, 150 + policyIds, 151 + orgId, 152 + actorId, 153 + actorEmail, 154 + actionIdToParameters, 155 + actorNote, 156 + } = opts; 157 + 158 + validateActorNote(actorNote); 159 + 124 160 const [actions, policies, itemType] = await Promise.all([ 125 161 this.moderationConfigService.getActions({ 126 162 orgId, ··· 142 178 throw new Error(`Item type ${itemTypeId} not found for org ${orgId}`); 143 179 } 144 180 181 + // Validate moderator-supplied parameter values once per action up front; 182 + // throws BadRequestError before any side-effecting publish if any value 183 + // doesn't match its spec. 184 + const validatedParameters = this.#validateParametersForActions( 185 + actions, 186 + actionIdToParameters ?? null, 187 + ); 188 + 145 189 const correlationId = toCorrelationId({ 146 190 type: 'manual-action-run', 147 191 id: uuidv1(), ··· 163 207 }) 164 208 )?.latestSubmission; 165 209 166 - // If the item isn't found, pass it along to the action publisher anyway 167 - // without the full submission. In this case, we'll be losing some 168 - // information in the logging but it's better than not submitting the 169 - // action at all, and it's possible that the item was never submitted to 170 - // us at all. 171 - if (itemSubmission === undefined) { 172 - return this.actionPublisher.publishActions( 173 - actions.map((action) => ({ 174 - action, 175 - matchingRules: undefined, 176 - ruleEnvironment: undefined, 177 - policies, 178 - })), 179 - { 180 - orgId, 181 - correlationId, 182 - targetItem: { 183 - itemId, 184 - itemType: { id: itemType.id, kind: itemType.kind, name: itemType.name }, 185 - }, 186 - actorId, 187 - actorEmail, 188 - }, 189 - ); 190 - } 191 - return this.actionPublisher.publishActions( 192 - actions.map((action) => ({ 193 - action, 194 - matchingRules: undefined, 195 - ruleEnvironment: undefined, 196 - policies, 197 - })), 198 - { 199 - orgId, 200 - correlationId, 201 - targetItem: itemSubmission, 202 - actorId, 203 - actorEmail, 204 - }, 205 - ); 210 + const triggered = actions.map((action) => ({ 211 + action, 212 + matchingRules: undefined as undefined, 213 + ruleEnvironment: undefined as undefined, 214 + policies, 215 + customMrtApiParamDecisionPayload: validatedParameters.get(action.id), 216 + })); 217 + 218 + // If the item isn't found, pass it along to the action publisher 219 + // anyway without the full submission. We lose some logging fidelity 220 + // but it's better than refusing to submit, and the item may have 221 + // never been submitted to us at all. 222 + const targetItem = itemSubmission ?? { 223 + itemId, 224 + itemType: { id: itemType.id, kind: itemType.kind, name: itemType.name }, 225 + }; 226 + return this.actionPublisher.publishActions(triggered, { 227 + orgId, 228 + correlationId, 229 + targetItem, 230 + actorId, 231 + actorEmail, 232 + actorNote: actorNote ?? undefined, 233 + }); 206 234 }), 207 235 ), 208 236 ); 209 237 } 238 + 239 + /** 240 + * Validate the supplied per-action parameter map against each action's 241 + * stored spec and return a Map of `actionId -> validated values`. Actions 242 + * with no supplied values and no required parameters get `undefined` (no 243 + * entry in the result Map) so the publisher can treat absence as 244 + * "no runtime params for this action". 245 + */ 246 + #validateParametersForActions( 247 + actions: readonly Action[], 248 + rawByActionId: Readonly<Record<string, Record<string, unknown>>> | null, 249 + ): Map<string, Record<string, unknown> | undefined> { 250 + const out = new Map<string, Record<string, unknown> | undefined>(); 251 + for (const action of actions) { 252 + const spec = parseStoredParameters( 253 + action.actionType === 'CUSTOM_ACTION' ? action.customMrtApiParams : null, 254 + ); 255 + const supplied = rawByActionId?.[action.id]; 256 + if (spec.length === 0 && (supplied === undefined || Object.keys(supplied).length === 0)) { 257 + // No spec, no values — nothing to do for this action. 258 + continue; 259 + } 260 + // Throws BadRequestError on missing required, type mismatch, unknown 261 + // keys, etc. Validation runs even when no values are supplied so that 262 + // missing-required-with-no-default is caught. 263 + const validated = validateActionParameterValues(spec, supplied ?? null); 264 + out.set(action.id, Object.keys(validated).length > 0 ? validated : undefined); 265 + } 266 + return out; 267 + } 210 268 } 211 269 212 270 export default inject( ··· 219 277 ], 220 278 ActionAPI, 221 279 ); 222 - export type { ActionAPI }; 280 + export { ActionAPI };
+162
server/graphql/generated.ts
··· 141 141 readonly itemTypes: ReadonlyArray<GQLItemType>; 142 142 readonly name: Scalars['String']['output']; 143 143 readonly orgId: Scalars['String']['output']; 144 + readonly parameters: ReadonlyArray<GQLActionParameter>; 144 145 readonly penalty: GQLUserPenaltySeverity; 145 146 }; 146 147 ··· 165 166 readonly type: ReadonlyArray<Scalars['String']['output']>; 166 167 }; 167 168 169 + /** 170 + * Definition of a single runtime parameter on an action. The moderator is 171 + * prompted for a value at execution time; the value is included in the 172 + * webhook payload under the parameter's `name`. 173 + */ 174 + export type GQLActionParameter = { 175 + readonly __typename?: 'ActionParameter'; 176 + /** Pre-filled value shown to the moderator. Shape matches `type`. */ 177 + readonly defaultValue?: Maybe<Scalars['JSON']['output']>; 178 + readonly description?: Maybe<Scalars['String']['output']>; 179 + readonly displayName: Scalars['String']['output']; 180 + /** NUMBER only: inclusive maximum. */ 181 + readonly max?: Maybe<Scalars['Float']['output']>; 182 + /** STRING only: inclusive maximum length in characters. */ 183 + readonly maxLength?: Maybe<Scalars['Int']['output']>; 184 + /** NUMBER only: inclusive minimum. */ 185 + readonly min?: Maybe<Scalars['Float']['output']>; 186 + /** Key under which the value is sent in the webhook payload. */ 187 + readonly name: Scalars['String']['output']; 188 + readonly options?: Maybe<ReadonlyArray<GQLActionParameterOption>>; 189 + readonly required: Scalars['Boolean']['output']; 190 + readonly type: GQLActionParameterType; 191 + }; 192 + 193 + export type GQLActionParameterInput = { 194 + readonly defaultValue?: InputMaybe<Scalars['JSON']['input']>; 195 + readonly description?: InputMaybe<Scalars['String']['input']>; 196 + readonly displayName: Scalars['String']['input']; 197 + readonly max?: InputMaybe<Scalars['Float']['input']>; 198 + readonly maxLength?: InputMaybe<Scalars['Int']['input']>; 199 + readonly min?: InputMaybe<Scalars['Float']['input']>; 200 + readonly name: Scalars['String']['input']; 201 + readonly options?: InputMaybe<ReadonlyArray<GQLActionParameterOptionInput>>; 202 + readonly required: Scalars['Boolean']['input']; 203 + readonly type: GQLActionParameterType; 204 + }; 205 + 206 + export type GQLActionParameterOption = { 207 + readonly __typename?: 'ActionParameterOption'; 208 + readonly label: Scalars['String']['output']; 209 + readonly value: Scalars['String']['output']; 210 + }; 211 + 212 + export type GQLActionParameterOptionInput = { 213 + readonly label: Scalars['String']['input']; 214 + readonly value: Scalars['String']['input']; 215 + }; 216 + 217 + export const GQLActionParameterType = { 218 + Boolean: 'BOOLEAN', 219 + Multiselect: 'MULTISELECT', 220 + Number: 'NUMBER', 221 + Select: 'SELECT', 222 + String: 'STRING', 223 + } as const; 224 + 225 + export type GQLActionParameterType = 226 + (typeof GQLActionParameterType)[keyof typeof GQLActionParameterType]; 168 227 export const GQLActionSource = { 169 228 AutomatedRule: 'AUTOMATED_RULE', 170 229 ManualActionRun: 'MANUAL_ACTION_RUN', ··· 719 778 readonly description?: InputMaybe<Scalars['String']['input']>; 720 779 readonly itemTypeIds: ReadonlyArray<Scalars['ID']['input']>; 721 780 readonly name: Scalars['String']['input']; 781 + readonly parameters?: InputMaybe<ReadonlyArray<GQLActionParameterInput>>; 722 782 }; 723 783 724 784 export type GQLCreateBacktestInput = { ··· 882 942 readonly callbackUrl: Scalars['String']['output']; 883 943 readonly callbackUrlBody?: Maybe<Scalars['JSONObject']['output']>; 884 944 readonly callbackUrlHeaders?: Maybe<Scalars['JSONObject']['output']>; 945 + /** 946 + * Deprecated alias for `parameters` retained for back-compat with the 947 + * initial MRT-only parameter implementation. New consumers should read 948 + * `parameters` instead. 949 + * @deprecated Use `parameters` instead. 950 + */ 885 951 readonly customMrtApiParams: ReadonlyArray<Maybe<GQLCustomMrtApiParamSpec>>; 886 952 readonly description?: Maybe<Scalars['String']['output']>; 887 953 readonly id: Scalars['ID']['output']; 888 954 readonly itemTypes: ReadonlyArray<GQLItemType>; 889 955 readonly name: Scalars['String']['output']; 890 956 readonly orgId: Scalars['String']['output']; 957 + readonly parameters: ReadonlyArray<GQLActionParameter>; 891 958 readonly penalty: GQLUserPenaltySeverity; 892 959 }; 893 960 ··· 1116 1183 readonly itemTypes: ReadonlyArray<GQLItemType>; 1117 1184 readonly name: Scalars['String']['output']; 1118 1185 readonly orgId: Scalars['String']['output']; 1186 + readonly parameters: ReadonlyArray<GQLActionParameter>; 1119 1187 readonly penalty: GQLUserPenaltySeverity; 1120 1188 }; 1121 1189 ··· 1127 1195 readonly itemTypes: ReadonlyArray<GQLItemType>; 1128 1196 readonly name: Scalars['String']['output']; 1129 1197 readonly orgId: Scalars['String']['output']; 1198 + readonly parameters: ReadonlyArray<GQLActionParameter>; 1130 1199 readonly penalty: GQLUserPenaltySeverity; 1131 1200 }; 1132 1201 ··· 1138 1207 readonly itemTypes: ReadonlyArray<GQLItemType>; 1139 1208 readonly name: Scalars['String']['output']; 1140 1209 readonly orgId: Scalars['String']['output']; 1210 + readonly parameters: ReadonlyArray<GQLActionParameter>; 1141 1211 readonly penalty: GQLUserPenaltySeverity; 1142 1212 }; 1143 1213 ··· 1216 1286 readonly actionIds: ReadonlyArray<Scalars['String']['input']>; 1217 1287 readonly itemIds: ReadonlyArray<Scalars['String']['input']>; 1218 1288 readonly itemTypeId: Scalars['String']['input']; 1289 + /** 1290 + * Optional moderator-authored note explaining why this action was taken. 1291 + * Sent to the action's webhook as `actorNote` and persisted to the action 1292 + * execution audit log. 1293 + */ 1294 + readonly note?: InputMaybe<Scalars['String']['input']>; 1295 + /** 1296 + * Optional map of `actionId` -> `{ paramName: value }` carrying 1297 + * moderator-supplied runtime parameter values. Each map is validated against 1298 + * the action's parameter spec server-side before publish; invalid values 1299 + * reject the entire request. 1300 + */ 1301 + readonly parameters?: InputMaybe<Scalars['JSONObject']['input']>; 1219 1302 readonly policyIds: ReadonlyArray<Scalars['String']['input']>; 1220 1303 }; 1221 1304 ··· 4472 4555 readonly id: Scalars['ID']['input']; 4473 4556 readonly itemTypeIds?: InputMaybe<ReadonlyArray<Scalars['ID']['input']>>; 4474 4557 readonly name?: InputMaybe<Scalars['String']['input']>; 4558 + /** Replace the parameter list (`[]` clears it). Omit to leave unchanged. */ 4559 + readonly parameters?: InputMaybe<ReadonlyArray<GQLActionParameterInput>>; 4475 4560 }; 4476 4561 4477 4562 export type GQLUpdateContentItemTypeInput = { ··· 5396 5481 >; 5397 5482 ActionData: ResolverTypeWrapper<GQLActionData>; 5398 5483 ActionNameExistsError: ResolverTypeWrapper<GQLActionNameExistsError>; 5484 + ActionParameter: ResolverTypeWrapper<GQLActionParameter>; 5485 + ActionParameterInput: GQLActionParameterInput; 5486 + ActionParameterOption: ResolverTypeWrapper<GQLActionParameterOption>; 5487 + ActionParameterOptionInput: GQLActionParameterOptionInput; 5488 + ActionParameterType: GQLActionParameterType; 5399 5489 ActionSource: GQLActionSource; 5400 5490 ActionStatisticsFilters: GQLActionStatisticsFilters; 5401 5491 ActionStatisticsGroupByColumns: GQLActionStatisticsGroupByColumns; ··· 6241 6331 ActionBase: GQLResolversInterfaceTypes<GQLResolversParentTypes>['ActionBase']; 6242 6332 ActionData: GQLActionData; 6243 6333 ActionNameExistsError: GQLActionNameExistsError; 6334 + ActionParameter: GQLActionParameter; 6335 + ActionParameterInput: GQLActionParameterInput; 6336 + ActionParameterOption: GQLActionParameterOption; 6337 + ActionParameterOptionInput: GQLActionParameterOptionInput; 6244 6338 ActionStatisticsFilters: GQLActionStatisticsFilters; 6245 6339 ActionStatisticsInput: GQLActionStatisticsInput; 6246 6340 AddAccessibleQueuesToUserInput: GQLAddAccessibleQueuesToUserInput; ··· 6984 7078 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 6985 7079 }; 6986 7080 7081 + export type GQLActionParameterResolvers< 7082 + ContextType = Context, 7083 + ParentType extends GQLResolversParentTypes['ActionParameter'] = 7084 + GQLResolversParentTypes['ActionParameter'], 7085 + > = { 7086 + defaultValue?: Resolver< 7087 + Maybe<GQLResolversTypes['JSON']>, 7088 + ParentType, 7089 + ContextType 7090 + >; 7091 + description?: Resolver< 7092 + Maybe<GQLResolversTypes['String']>, 7093 + ParentType, 7094 + ContextType 7095 + >; 7096 + displayName?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 7097 + max?: Resolver<Maybe<GQLResolversTypes['Float']>, ParentType, ContextType>; 7098 + maxLength?: Resolver< 7099 + Maybe<GQLResolversTypes['Int']>, 7100 + ParentType, 7101 + ContextType 7102 + >; 7103 + min?: Resolver<Maybe<GQLResolversTypes['Float']>, ParentType, ContextType>; 7104 + name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 7105 + options?: Resolver< 7106 + Maybe<ReadonlyArray<GQLResolversTypes['ActionParameterOption']>>, 7107 + ParentType, 7108 + ContextType 7109 + >; 7110 + required?: Resolver<GQLResolversTypes['Boolean'], ParentType, ContextType>; 7111 + type?: Resolver< 7112 + GQLResolversTypes['ActionParameterType'], 7113 + ParentType, 7114 + ContextType 7115 + >; 7116 + }; 7117 + 7118 + export type GQLActionParameterOptionResolvers< 7119 + ContextType = Context, 7120 + ParentType extends GQLResolversParentTypes['ActionParameterOption'] = 7121 + GQLResolversParentTypes['ActionParameterOption'], 7122 + > = { 7123 + label?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 7124 + value?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 7125 + }; 7126 + 6987 7127 export type GQLAddAccessibleQueuesToUserResponseResolvers< 6988 7128 ContextType = Context, 6989 7129 ParentType extends ··· 8058 8198 >; 8059 8199 name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8060 8200 orgId?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8201 + parameters?: Resolver< 8202 + ReadonlyArray<GQLResolversTypes['ActionParameter']>, 8203 + ParentType, 8204 + ContextType 8205 + >; 8061 8206 penalty?: Resolver< 8062 8207 GQLResolversTypes['UserPenaltySeverity'], 8063 8208 ParentType, ··· 8384 8529 >; 8385 8530 name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8386 8531 orgId?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8532 + parameters?: Resolver< 8533 + ReadonlyArray<GQLResolversTypes['ActionParameter']>, 8534 + ParentType, 8535 + ContextType 8536 + >; 8387 8537 penalty?: Resolver< 8388 8538 GQLResolversTypes['UserPenaltySeverity'], 8389 8539 ParentType, ··· 8415 8565 >; 8416 8566 name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8417 8567 orgId?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8568 + parameters?: Resolver< 8569 + ReadonlyArray<GQLResolversTypes['ActionParameter']>, 8570 + ParentType, 8571 + ContextType 8572 + >; 8418 8573 penalty?: Resolver< 8419 8574 GQLResolversTypes['UserPenaltySeverity'], 8420 8575 ParentType, ··· 8446 8601 >; 8447 8602 name?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8448 8603 orgId?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 8604 + parameters?: Resolver< 8605 + ReadonlyArray<GQLResolversTypes['ActionParameter']>, 8606 + ParentType, 8607 + ContextType 8608 + >; 8449 8609 penalty?: Resolver< 8450 8610 GQLResolversTypes['UserPenaltySeverity'], 8451 8611 ParentType, ··· 14288 14448 ActionBase?: GQLActionBaseResolvers<ContextType>; 14289 14449 ActionData?: GQLActionDataResolvers<ContextType>; 14290 14450 ActionNameExistsError?: GQLActionNameExistsErrorResolvers<ContextType>; 14451 + ActionParameter?: GQLActionParameterResolvers<ContextType>; 14452 + ActionParameterOption?: GQLActionParameterOptionResolvers<ContextType>; 14291 14453 AddAccessibleQueuesToUserResponse?: GQLAddAccessibleQueuesToUserResponseResolvers<ContextType>; 14292 14454 AddCommentFailedError?: GQLAddCommentFailedErrorResolvers<ContextType>; 14293 14455 AddFavoriteMRTQueueSuccessResponse?: GQLAddFavoriteMrtQueueSuccessResponseResolvers<ContextType>;
+142 -8
server/graphql/modules/action.ts
··· 1 1 import { isCoopErrorOfType } from '../../utils/errors.js'; 2 2 import { assertUnreachable } from '../../utils/misc.js'; 3 3 import { 4 + type GQLActionParameter, 4 5 type GQLActionResolvers, 5 6 type GQLCustomActionResolvers, 6 7 type GQLCustomMrtApiParamSpec, ··· 12 13 } from '../generated.js'; 13 14 import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; 14 15 import { unauthenticatedError } from '../utils/errors.js'; 16 + import { parseStoredParameters } from '../../services/moderationConfigService/index.js'; 15 17 16 18 const typeDefs = /* GraphQL */ ` 17 19 interface ActionBase { ··· 22 24 penalty: UserPenaltySeverity! 23 25 applyUserStrikes: Boolean 24 26 itemTypes: [ItemType!]! 27 + parameters: [ActionParameter!]! 28 + } 29 + 30 + enum ActionParameterType { 31 + STRING 32 + NUMBER 33 + BOOLEAN 34 + SELECT 35 + MULTISELECT 36 + } 37 + 38 + type ActionParameterOption { 39 + value: String! 40 + label: String! 41 + } 42 + 43 + input ActionParameterOptionInput { 44 + value: String! 45 + label: String! 46 + } 47 + 48 + """ 49 + Definition of a single runtime parameter on an action. The moderator is 50 + prompted for a value at execution time; the value is included in the 51 + webhook payload under the parameter's \`name\`. 52 + """ 53 + type ActionParameter { 54 + """Key under which the value is sent in the webhook payload.""" 55 + name: String! 56 + displayName: String! 57 + description: String 58 + type: ActionParameterType! 59 + required: Boolean! 60 + options: [ActionParameterOption!] 61 + """NUMBER only: inclusive minimum.""" 62 + min: Float 63 + """NUMBER only: inclusive maximum.""" 64 + max: Float 65 + """STRING only: inclusive maximum length in characters.""" 66 + maxLength: Int 67 + """Pre-filled value shown to the moderator. Shape matches \`type\`.""" 68 + defaultValue: JSON 69 + } 70 + 71 + input ActionParameterInput { 72 + name: String! 73 + displayName: String! 74 + description: String 75 + type: ActionParameterType! 76 + required: Boolean! 77 + options: [ActionParameterOptionInput!] 78 + min: Float 79 + max: Float 80 + maxLength: Int 81 + defaultValue: JSON 25 82 } 26 83 27 84 type CustomAction implements ActionBase { ··· 35 92 callbackUrlHeaders: JSONObject 36 93 callbackUrlBody: JSONObject 37 94 applyUserStrikes: Boolean 95 + parameters: [ActionParameter!]! 96 + """ 97 + Deprecated alias for \`parameters\` retained for back-compat with the 98 + initial MRT-only parameter implementation. New consumers should read 99 + \`parameters\` instead. 100 + """ 38 101 customMrtApiParams: [CustomMrtApiParamSpec]! 102 + @deprecated(reason: "Use \`parameters\` instead.") 39 103 } 40 104 41 105 type CustomMrtApiParamSpec { ··· 52 116 penalty: UserPenaltySeverity! 53 117 itemTypes: [ItemType!]! 54 118 applyUserStrikes: Boolean 119 + parameters: [ActionParameter!]! 55 120 } 56 121 57 122 type EnqueueToNcmecAction implements ActionBase { ··· 62 127 penalty: UserPenaltySeverity! 63 128 itemTypes: [ItemType!]! 64 129 applyUserStrikes: Boolean 130 + parameters: [ActionParameter!]! 65 131 } 66 132 67 133 type EnqueueAuthorToMrtAction implements ActionBase { ··· 72 138 penalty: UserPenaltySeverity! 73 139 itemTypes: [ItemType!]! 74 140 applyUserStrikes: Boolean! 141 + parameters: [ActionParameter!]! 75 142 } 76 143 77 144 union Action = ··· 88 155 callbackUrlHeaders: JSONObject 89 156 callbackUrlBody: JSONObject 90 157 applyUserStrikes: Boolean 158 + parameters: [ActionParameterInput!] 91 159 } 92 160 93 161 input UpdateActionInput { ··· 99 167 callbackUrlHeaders: JSONObject 100 168 callbackUrlBody: JSONObject 101 169 applyUserStrikes: Boolean 170 + """ 171 + Replace the parameter list (\`[]\` clears it). Omit to leave unchanged. 172 + """ 173 + parameters: [ActionParameterInput!] 102 174 } 103 175 104 176 type ActionNameExistsError implements Error { ··· 132 204 actionIds: [String!]! 133 205 itemTypeId: String! 134 206 policyIds: [String!]! 207 + """ 208 + Optional map of \`actionId\` -> \`{ paramName: value }\` carrying 209 + moderator-supplied runtime parameter values. Each map is validated against 210 + the action's parameter spec server-side before publish; invalid values 211 + reject the entire request. 212 + """ 213 + parameters: JSONObject 214 + """ 215 + Optional moderator-authored note explaining why this action was taken. 216 + Sent to the action's webhook as \`actorNote\` and persisted to the action 217 + execution audit log. 218 + """ 219 + note: String 135 220 } 136 221 137 222 type ExecuteActionResponse { ··· 179 264 }, 180 265 }; 181 266 267 + // Project the loose `JsonValue | null` stored in `actions.custom_mrt_api_params` 268 + // to the typed `ActionParameter` shape via the service-layer 269 + // `parseStoredParameters` (single source of truth for the projection rules). 270 + function projectParameters(value: unknown): GQLActionParameter[] { 271 + return parseStoredParameters(value).map((p) => ({ 272 + name: p.name, 273 + displayName: p.displayName, 274 + description: p.description ?? null, 275 + type: p.type as GQLActionParameter['type'], 276 + required: p.required, 277 + options: p.options ? p.options.map((o) => ({ value: o.value, label: o.label })) : null, 278 + min: p.min ?? null, 279 + max: p.max ?? null, 280 + maxLength: p.maxLength ?? null, 281 + defaultValue: 282 + p.defaultValue === undefined 283 + ? null 284 + : (p.defaultValue as GQLActionParameter['defaultValue']), 285 + })); 286 + } 287 + 288 + // `customMrtApiParams` lives only on CustomAction in the service-layer types, 289 + // but the underlying DB column is shared by every action type. Read it 290 + // defensively so the GraphQL projection works for all four action types. 291 + function readRawParameters(parent: unknown): unknown { 292 + if (typeof parent !== 'object' || parent === null) return null; 293 + return (parent as { customMrtApiParams?: unknown }).customMrtApiParams ?? null; 294 + } 295 + 182 296 const CustomAction: GQLCustomActionResolvers = { 297 + parameters(parent) { 298 + return projectParameters(parent.customMrtApiParams); 299 + }, 183 300 customMrtApiParams(parent) { 184 301 return Array.isArray(parent.customMrtApiParams) 185 302 ? (parent.customMrtApiParams as readonly GQLCustomMrtApiParamSpec[]) ··· 198 315 }; 199 316 200 317 const EnqueueAuthorToMrtAction: GQLEnqueueAuthorToMrtActionResolvers = { 318 + parameters(parent) { 319 + return projectParameters(readRawParameters(parent)); 320 + }, 201 321 async itemTypes(action, _, context) { 202 322 const user = context.getUser(); 203 323 if (user == null) { ··· 211 331 }; 212 332 213 333 const EnqueueToMrtAction: GQLEnqueueToMrtActionResolvers = { 334 + parameters(parent) { 335 + return projectParameters(readRawParameters(parent)); 336 + }, 214 337 async itemTypes(action, _, context) { 215 338 const user = context.getUser(); 216 339 if (user == null) { ··· 224 347 }; 225 348 226 349 const EnqueueToNcmecAction: GQLEnqueueToNcmecActionResolvers = { 350 + parameters(parent) { 351 + return projectParameters(readRawParameters(parent)); 352 + }, 227 353 async itemTypes(action, _, context) { 228 354 const user = context.getUser(); 229 355 if (user == null) { ··· 307 433 const { orgId, id, email } = user; 308 434 309 435 const actionResults = 310 - await context.dataSources.actionAPI.bulkExecuteActions( 311 - params.input.itemIds, 312 - params.input.actionIds, 313 - params.input.itemTypeId, 314 - params.input.policyIds, 436 + await context.dataSources.actionAPI.bulkExecuteActions({ 437 + itemIds: params.input.itemIds, 438 + actionIds: params.input.actionIds, 439 + itemTypeId: params.input.itemTypeId, 440 + policyIds: params.input.policyIds, 315 441 orgId, 316 - id, 317 - email, 318 - ); 442 + actorId: id, 443 + actorEmail: email, 444 + // GraphQL `JSONObject` arrives as a plain object; the datasource 445 + // narrows + validates per-action against each spec. 446 + actionIdToParameters: 447 + (params.input.parameters ?? null) as Record< 448 + string, 449 + Record<string, unknown> 450 + > | null, 451 + actorNote: params.input.note ?? null, 452 + }); 319 453 320 454 return { 321 455 results: actionResults.flat().map((actionResult) => ({
+28 -2
server/routes/action/ActionRoutes.ts
··· 1 1 import { type ItemIdentifier } from '@roostorg/types'; 2 + import { type JsonObject, type JsonValue } from 'type-fest'; 2 3 3 4 import { route } from '../../utils/route-helpers.js'; 4 5 import { createApiKeyMiddleware } from '../../utils/apiKeyMiddleware.js'; 5 6 import { type Controller } from '../index.js'; 7 + import { type JSONSchemaV4 } from '../../utils/json-schema-types.js'; 8 + import { MAX_ACTOR_NOTE_LENGTH } from '../../services/moderationConfigService/index.js'; 6 9 import submitAction from './submitAction.js'; 7 10 8 - export type SubmitActionInput = { 11 + export type SubmitActionInput = JsonObject & { 9 12 actionId: string; 10 13 itemId: string; 11 14 itemTypeId: string; 12 15 policyIds?: string[]; 13 16 reportedItems?: ItemIdentifier[]; 14 17 actorId?: string; 18 + /** 19 + * Optional moderator-supplied parameter values. Validated against the 20 + * action's parameter spec server-side in `submitAction.ts` before publish; 21 + * the body schema only enforces it's a JSON object so the imperative 22 + * validator has something well-formed to inspect. 23 + */ 24 + parameters?: Record<string, JsonValue>; 25 + /** Optional moderator note. Sent to the webhook as `actorNote`. */ 26 + note?: string; 15 27 }; 16 28 17 29 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions ··· 21 33 route.post<SubmitActionInput, undefined>( 22 34 '/', 23 35 { 36 + // The `parameters` property accepts an arbitrary JSON object whose 37 + // shape is validated imperatively in `submitAction.ts` against the 38 + // action's stored spec. AJV draft-04 forbids `required: []`, but the 39 + // inferred TS schema type for `Record<string, JsonValue>` demands a 40 + // (non-empty) `required` array, so we cast the whole `bodySchema` 41 + // once and rely on the runtime AJV check to catch any drift. 24 42 bodySchema: { 25 43 $schema: 'http://json-schema.org/draft-04/schema#', 26 44 title: 'ActionInputModel', ··· 59 77 actorId: { 60 78 type: 'string', 61 79 }, 80 + parameters: { 81 + type: 'object', 82 + additionalProperties: true, 83 + }, 84 + note: { 85 + type: 'string', 86 + maxLength: MAX_ACTOR_NOTE_LENGTH, 87 + }, 62 88 }, 63 89 required: ['actionId', 'itemId', 'itemTypeId'], 64 - }, 90 + } as unknown as JSONSchemaV4<SubmitActionInput>, 65 91 }, 66 92 (deps) => [createApiKeyMiddleware<SubmitActionInput, undefined>(deps), submitAction(deps)], 67 93 ),
+187
server/routes/action/submitAction.test.ts
··· 1 + import { type Request, type Response } from 'express'; 2 + 3 + import submitAction from './submitAction.js'; 4 + 5 + type Handler = ReturnType<typeof submitAction>; 6 + 7 + function makeDeps( 8 + overrides?: Partial<{ 9 + action: Record<string, unknown>; 10 + user: Record<string, unknown> | undefined; 11 + }>, 12 + ) { 13 + const action = overrides?.action ?? { 14 + id: 'action-1', 15 + orgId: 'org-1', 16 + name: 'Ban User', 17 + actionType: 'CUSTOM_ACTION', 18 + callbackUrl: 'https://example.com', 19 + callbackUrlHeaders: null, 20 + callbackUrlBody: null, 21 + applyUserStrikes: false, 22 + customMrtApiParams: [ 23 + { 24 + name: 'num_days', 25 + displayName: 'Number of days', 26 + type: 'NUMBER', 27 + required: true, 28 + }, 29 + ], 30 + }; 31 + 32 + const publishActions = jest.fn().mockResolvedValue([]); 33 + const getActions = jest.fn().mockResolvedValue([action]); 34 + const getPolicies = jest.fn().mockResolvedValue([]); 35 + const getItemTypeEventuallyConsistent = jest.fn().mockResolvedValue({ 36 + id: 'type-1', 37 + kind: 'CONTENT', 38 + name: 'Social Post', 39 + }); 40 + const getGraphQLUserFromId = jest 41 + .fn() 42 + .mockResolvedValue( 43 + overrides?.user ?? { id: 'user-1', email: 'mod@example.com' }, 44 + ); 45 + 46 + const handler = submitAction({ 47 + ActionPublisher: { publishActions }, 48 + ModerationConfigService: { getActions, getPolicies }, 49 + getItemTypeEventuallyConsistent, 50 + UserAPIDataSource: { getGraphQLUserFromId }, 51 + } as never) as Handler; 52 + 53 + return { handler, publishActions, getActions }; 54 + } 55 + 56 + function makeReq(body: Record<string, unknown>): Request { 57 + return { 58 + orgId: 'org-1', 59 + body, 60 + } as unknown as Request; 61 + } 62 + 63 + function makeRes(): Response { 64 + const res = { 65 + status: jest.fn().mockReturnThis(), 66 + end: jest.fn(), 67 + }; 68 + return res as unknown as Response; 69 + } 70 + 71 + const validBody = { 72 + itemId: 'item-1', 73 + itemTypeId: 'type-1', 74 + actionId: 'action-1', 75 + parameters: { num_days: 7 }, 76 + note: 'Repeat offender', 77 + }; 78 + 79 + describe('submitAction (REST handler)', () => { 80 + it('forwards validated parameters and the moderator note to the publisher and 202s', async () => { 81 + const { handler, publishActions } = makeDeps(); 82 + const res = makeRes(); 83 + const next = jest.fn(); 84 + 85 + await handler(makeReq(validBody), res, next); 86 + 87 + expect(next).not.toHaveBeenCalled(); 88 + expect(publishActions).toHaveBeenCalledTimes(1); 89 + const [triggered, ctx] = publishActions.mock.calls[0]; 90 + expect(triggered[0].customMrtApiParamDecisionPayload).toEqual({ 91 + num_days: 7, 92 + }); 93 + expect(ctx.actorNote).toBe('Repeat offender'); 94 + expect(res.status).toHaveBeenCalledWith(202); 95 + expect(res.end).toHaveBeenCalled(); 96 + }); 97 + 98 + it('rejects (next() with a 400) when a required parameter is missing, and never publishes', async () => { 99 + const { handler, publishActions } = makeDeps(); 100 + const res = makeRes(); 101 + const next = jest.fn(); 102 + 103 + await handler( 104 + makeReq({ ...validBody, parameters: {} }), 105 + res, 106 + next, 107 + ); 108 + 109 + expect(publishActions).not.toHaveBeenCalled(); 110 + expect(next).toHaveBeenCalledTimes(1); 111 + const err = next.mock.calls[0][0] as { status?: number; message?: string }; 112 + expect(err.status).toBe(400); 113 + }); 114 + 115 + it('rejects unknown parameter keys with a 400', async () => { 116 + const { handler, publishActions } = makeDeps(); 117 + const res = makeRes(); 118 + const next = jest.fn(); 119 + 120 + await handler( 121 + makeReq({ 122 + ...validBody, 123 + parameters: { num_days: 7, bogus: 'x' }, 124 + }), 125 + res, 126 + next, 127 + ); 128 + 129 + expect(publishActions).not.toHaveBeenCalled(); 130 + expect(next).toHaveBeenCalledTimes(1); 131 + expect((next.mock.calls[0][0] as { status?: number }).status).toBe(400); 132 + }); 133 + 134 + it('rejects type-mismatched parameter values with a 400', async () => { 135 + const { handler, publishActions } = makeDeps(); 136 + const res = makeRes(); 137 + const next = jest.fn(); 138 + 139 + await handler( 140 + makeReq({ 141 + ...validBody, 142 + parameters: { num_days: 'seven' }, 143 + }), 144 + res, 145 + next, 146 + ); 147 + 148 + expect(publishActions).not.toHaveBeenCalled(); 149 + expect(next).toHaveBeenCalledTimes(1); 150 + expect((next.mock.calls[0][0] as { status?: number }).status).toBe(400); 151 + }); 152 + 153 + it('publishes successfully when parameters are absent and the action has no spec', async () => { 154 + const { handler, publishActions } = makeDeps({ 155 + action: { 156 + id: 'action-1', 157 + orgId: 'org-1', 158 + name: 'Plain Action', 159 + actionType: 'CUSTOM_ACTION', 160 + callbackUrl: 'https://example.com', 161 + callbackUrlHeaders: null, 162 + callbackUrlBody: null, 163 + applyUserStrikes: false, 164 + customMrtApiParams: null, 165 + }, 166 + }); 167 + const res = makeRes(); 168 + const next = jest.fn(); 169 + 170 + await handler( 171 + makeReq({ 172 + itemId: 'item-1', 173 + itemTypeId: 'type-1', 174 + actionId: 'action-1', 175 + }), 176 + res, 177 + next, 178 + ); 179 + 180 + expect(next).not.toHaveBeenCalled(); 181 + expect(publishActions).toHaveBeenCalledTimes(1); 182 + const [triggered, ctx] = publishActions.mock.calls[0]; 183 + expect(triggered[0].customMrtApiParamDecisionPayload).toBeUndefined(); 184 + expect(ctx.actorNote).toBeUndefined(); 185 + expect(res.status).toHaveBeenCalledWith(202); 186 + }); 187 + });
+30 -2
server/routes/action/submitAction.ts
··· 2 2 3 3 import { type Dependencies } from '../../iocContainer/index.js'; 4 4 import { 5 + parseStoredParameters, 6 + validateActionParameterValues, 7 + } from '../../services/moderationConfigService/index.js'; 8 + import { 5 9 fromCorrelationId, 6 10 toCorrelationId, 7 11 } from '../../utils/correlationIds.js'; ··· 36 40 const { orgId } = req; 37 41 38 42 const { body } = req; 39 - const { itemId, itemTypeId, actionId, policyIds, actorId, reportedItems } = 40 - body; 43 + const { 44 + itemId, 45 + itemTypeId, 46 + actionId, 47 + policyIds, 48 + actorId, 49 + reportedItems, 50 + parameters, 51 + note, 52 + } = body; 41 53 const action = ( 42 54 await ModerationConfigService.getActions({ 43 55 orgId, ··· 84 96 ? await UserAPIDataSource.getGraphQLUserFromId({ id: actorId, orgId }) 85 97 : undefined; 86 98 99 + // Validate moderator-supplied parameter values against the action's 100 + // stored spec before publish. Throws BadRequestError (400) which the 101 + // standard Express error handler serializes for the client. 102 + const spec = parseStoredParameters( 103 + action.actionType === 'CUSTOM_ACTION' ? action.customMrtApiParams : null, 104 + ); 105 + let validatedParameters: Record<string, unknown> | undefined; 106 + try { 107 + const validated = validateActionParameterValues(spec, parameters ?? null); 108 + validatedParameters = Object.keys(validated).length > 0 ? validated : undefined; 109 + } catch (e) { 110 + return next(e); 111 + } 112 + 87 113 await ActionPublisher.publishActions( 88 114 [ 89 115 { ··· 92 118 ruleEnvironment: undefined, 93 119 policies, 94 120 reportedItems, 121 + customMrtApiParamDecisionPayload: validatedParameters, 95 122 }, 96 123 ], 97 124 { 98 125 orgId, 99 126 correlationId: requestId, 100 127 targetItem: { itemId, itemType }, 128 + actorNote: note, 101 129 ...(user ? { actorId: user.id, actorEmail: user.email } : {}), 102 130 }, 103 131 );
+227 -1
server/rule_engine/ActionPublisher.test.ts
··· 9 9 import getBottle, { type Dependencies } from '../iocContainer/index.js'; 10 10 import { ActionType } from '../services/moderationConfigService/index.js'; 11 11 import { type CorrelationId } from '../utils/correlationIds.js'; 12 - import { type ActionPublisher } from './ActionPublisher.js'; 12 + import { jsonParse } from '../utils/encoding.js'; 13 + import ActionPublisherDefault, { 14 + type ActionPublisher, 15 + } from './ActionPublisher.js'; 13 16 import { RuleEnvironment } from './RuleEngine.js'; 17 + 18 + // Hand-rolled mocks for `makeIsolatedPublisher` below — used by tests that 19 + // need to inspect outgoing webhook bodies without mutating the bottle's 20 + // shared `ActionPublisher` instance. 21 + type SpanLike = { 22 + setAttribute: () => void; 23 + recordException: () => void; 24 + isRecording: () => boolean; 25 + }; 26 + type TracerLike = { 27 + addActiveSpan: (_meta: unknown, fn: (span: SpanLike) => unknown) => unknown; 28 + getActiveSpan: () => undefined; 29 + }; 30 + 31 + function makeIsolatedPublisher(opts: { fetchHTTP: jest.Mock }) { 32 + const tracer: TracerLike = { 33 + addActiveSpan: (_meta, fn) => 34 + fn({ 35 + setAttribute: () => {}, 36 + recordException: () => {}, 37 + isRecording: () => false, 38 + }), 39 + getActiveSpan: () => undefined, 40 + }; 41 + const PublisherCtor = ActionPublisherDefault as unknown as new ( 42 + actionExecutionLogger: { logActionExecutions: jest.Mock }, 43 + tracer: TracerLike, 44 + fetchHTTP: jest.Mock, 45 + signingKeyPairService: { sign: jest.Mock }, 46 + manualReviewToolService: object, 47 + ncmecService: object, 48 + itemInvestigationService: { getItemByIdentifier: jest.Mock }, 49 + userStrikeService: { applyUserStrikeFromPublishedActions: jest.Mock }, 50 + ) => ActionPublisher; 51 + const logActionExecutions = jest.fn().mockResolvedValue(undefined); 52 + const publisher = new PublisherCtor( 53 + { logActionExecutions }, 54 + tracer, 55 + opts.fetchHTTP, 56 + { sign: jest.fn().mockResolvedValue(undefined) }, 57 + {}, 58 + {}, 59 + { getItemByIdentifier: jest.fn().mockResolvedValue(undefined) }, 60 + { 61 + applyUserStrikeFromPublishedActions: jest 62 + .fn() 63 + .mockResolvedValue(undefined), 64 + }, 65 + ); 66 + return { publisher, logActionExecutions }; 67 + } 14 68 15 69 describe('ActionPublisher', () => { 16 70 let container: Dependencies; ··· 126 180 logSpy.mock.calls.forEach((call) => { 127 181 expect(call[0].executions).toHaveLength(1); 128 182 }); 183 + 184 + logSpy.mockRestore(); 185 + }); 186 + 187 + it('builds the CUSTOM_ACTION webhook body with parameters merged into `custom` and actorNote at the top level', async () => { 188 + const fetchHTTP = jest 189 + .fn() 190 + .mockResolvedValue({ status: 200, ok: true }); 191 + const { publisher } = makeIsolatedPublisher({ fetchHTTP }); 192 + 193 + await publisher.publishActions( 194 + [ 195 + { 196 + action: { 197 + id: 'action-webhook', 198 + orgId: 'org-789', 199 + name: 'Ban User', 200 + description: null, 201 + applyUserStrikes: false, 202 + penalty: 'NONE' as const, 203 + actionType: ActionType.CUSTOM_ACTION, 204 + callbackUrl: 'https://example.com/webhook', 205 + callbackUrlHeaders: null, 206 + callbackUrlBody: { source: 'mrt' }, 207 + customMrtApiParams: null, 208 + }, 209 + policies: [], 210 + matchingRules: undefined, 211 + ruleEnvironment: undefined, 212 + customMrtApiParamDecisionPayload: { 213 + num_days: 30, 214 + reason: 'spam', 215 + }, 216 + }, 217 + ], 218 + { 219 + orgId: 'org-789', 220 + correlationId: 221 + 'manual-action-run:body-shape' as CorrelationId<'manual-action-run'>, 222 + targetItem: { 223 + itemId: 'item-789', 224 + itemType: { 225 + id: 'type-789', 226 + kind: 'CONTENT' as const, 227 + name: 'Social Post', 228 + }, 229 + }, 230 + actorEmail: 'mod@example.com', 231 + actorNote: 'Repeat offender', 232 + }, 233 + ); 234 + 235 + expect(fetchHTTP).toHaveBeenCalledTimes(1); 236 + const sentBody = jsonParse(fetchHTTP.mock.calls[0]?.[0].body); 237 + 238 + expect(sentBody.custom).toEqual({ 239 + source: 'mrt', 240 + num_days: 30, 241 + reason: 'spam', 242 + }); 243 + // `actorNote` is a top-level field, NOT under `custom`, so it can't 244 + // collide with a user-defined parameter named `actorNote`. 245 + expect(sentBody.actorNote).toBe('Repeat offender'); 246 + expect(sentBody.custom.actorNote).toBeUndefined(); 247 + expect(sentBody.actorEmail).toBe('mod@example.com'); 248 + }); 249 + 250 + it('omits actorNote from the webhook body entirely when no note is supplied', async () => { 251 + const fetchHTTP = jest 252 + .fn() 253 + .mockResolvedValue({ status: 200, ok: true }); 254 + const { publisher } = makeIsolatedPublisher({ fetchHTTP }); 255 + 256 + await publisher.publishActions( 257 + [ 258 + { 259 + action: { 260 + id: 'action-no-note', 261 + orgId: 'org-789', 262 + name: 'Action', 263 + description: null, 264 + applyUserStrikes: false, 265 + penalty: 'NONE' as const, 266 + actionType: ActionType.CUSTOM_ACTION, 267 + callbackUrl: 'https://example.com/webhook', 268 + callbackUrlHeaders: null, 269 + callbackUrlBody: null, 270 + customMrtApiParams: null, 271 + }, 272 + policies: [], 273 + matchingRules: undefined, 274 + ruleEnvironment: undefined, 275 + }, 276 + ], 277 + { 278 + orgId: 'org-789', 279 + correlationId: 280 + 'manual-action-run:no-note' as CorrelationId<'manual-action-run'>, 281 + targetItem: { 282 + itemId: 'item-no-note', 283 + itemType: { 284 + id: 'type-789', 285 + kind: 'CONTENT' as const, 286 + name: 'Social Post', 287 + }, 288 + }, 289 + }, 290 + ); 291 + 292 + const sentBody = jsonParse(fetchHTTP.mock.calls[0]?.[0].body); 293 + expect('actorNote' in sentBody).toBe(false); 294 + }); 295 + 296 + it('forwards moderator-supplied parameter values and actor note to the logger', async () => { 297 + const logSpy = jest.spyOn( 298 + container.ActionExecutionLogger, 299 + 'logActionExecutions', 300 + ); 301 + 302 + const triggeredActions = [ 303 + { 304 + action: { 305 + id: 'action-with-params', 306 + orgId: 'org-456', 307 + name: 'Action With Params', 308 + description: null, 309 + applyUserStrikes: false, 310 + penalty: 'NONE' as const, 311 + actionType: ActionType.CUSTOM_ACTION, 312 + callbackUrl: 'https://example.com/action', 313 + callbackUrlHeaders: null, 314 + callbackUrlBody: null, 315 + customMrtApiParams: null, 316 + }, 317 + policies: [], 318 + matchingRules: undefined, 319 + ruleEnvironment: undefined, 320 + // Mirrors what ActionApi.bulkExecuteActions hands to the publisher 321 + // after `validateActionParameterValues` runs. 322 + customMrtApiParamDecisionPayload: { num_days: 7, reason: 'spam' }, 323 + }, 324 + ]; 325 + 326 + const executionContext = { 327 + orgId: 'org-456', 328 + correlationId: 329 + 'manual-action-run:abc' as CorrelationId<'manual-action-run'>, 330 + targetItem: { 331 + itemId: 'item-456', 332 + itemType: { 333 + id: 'type-456', 334 + kind: 'CONTENT' as const, 335 + name: 'Social Post', 336 + }, 337 + }, 338 + actorId: 'actor-1', 339 + actorEmail: 'mod@example.com', 340 + actorNote: 'Repeat offender', 341 + }; 342 + 343 + await container.ActionPublisher.publishActions( 344 + triggeredActions, 345 + executionContext, 346 + ); 347 + 348 + expect(logSpy).toHaveBeenCalledTimes(1); 349 + const logged = logSpy.mock.calls[0]?.[0].executions[0]; 350 + expect(logged?.parameterValues).toEqual({ 351 + num_days: 7, 352 + reason: 'spam', 353 + }); 354 + expect(logged?.actorNote).toBe('Repeat offender'); 129 355 130 356 logSpy.mockRestore(); 131 357 });
+38 -3
server/rule_engine/ActionPublisher.ts
··· 56 56 actorId?: string; 57 57 reportedItems?: ItemIdentifier[]; 58 58 jobId?: string; 59 + /** 60 + * Validated moderator-supplied runtime parameter values for this action, 61 + * propagated into the audit log alongside the action itself. Persisted as 62 + * JSON in `analytics.ACTION_EXECUTIONS.parameters` so reviewers can see 63 + * exactly what the action ran with. 64 + */ 65 + parameterValues?: Record<string, unknown>; 66 + /** Optional moderator note. Persisted in `analytics.ACTION_EXECUTIONS.actor_note`. */ 67 + actorNote?: string; 59 68 }; 60 69 61 70 export type ActionResult<T extends ActionTargetItem> = { ··· 144 153 actorId?: string; 145 154 actorEmail?: string; 146 155 decisionReason?: string; 156 + /** 157 + * Optional moderator-authored note explaining why the action(s) ran. 158 + * Forwarded to CUSTOM_ACTION webhooks as `actorNote` and persisted by 159 + * the audit logger (PR 3). 160 + */ 161 + actorNote?: string; 147 162 }, 148 163 ): Promise<ActionResult<U>[]> { 149 - const { orgId, correlationId, targetItem, sync, actorId, actorEmail } = 150 - executionContext; 164 + const { 165 + orgId, 166 + correlationId, 167 + targetItem, 168 + sync, 169 + actorId, 170 + actorEmail, 171 + actorNote, 172 + } = executionContext; 151 173 152 174 // Apply user strikes from the actions that were triggered. 153 175 // we do this without awaiting to not block the action publishing ··· 211 233 reportedItems, 212 234 relatedRules, 213 235 customMrtApiParamDecisionPayload, 236 + actorNote, 214 237 ); 215 238 }, 216 239 ); ··· 241 264 correlationId, 242 265 actorId, 243 266 jobId, 267 + // Audit-trail context: persist what the moderator supplied 268 + // alongside the action itself so reviewers can see why and 269 + // with what values it ran (PR 3 for #377). 270 + parameterValues: customMrtApiParamDecisionPayload as 271 + | Record<string, unknown> 272 + | undefined, 273 + actorNote, 244 274 }, 245 275 ], 246 276 failed: success === false, ··· 272 302 string, 273 303 string | boolean | unknown 274 304 >, 305 + actorNote?: string, 275 306 ): Promise<boolean> { 276 307 return this.tracer.addActiveSpan( 277 308 { resource: 'actionPublisher', operation: 'publishAction' }, ··· 321 352 action: { id: action.id }, 322 353 custom: customBodyWithMrtParams, 323 354 actorEmail, 355 + // Top-level (not nested under `custom`) so the moderator note 356 + // can't collide with a user-defined parameter named 357 + // `actorNote`. Omitted from the body entirely when absent. 358 + ...(actorNote !== undefined ? { actorNote } : {}), 324 359 }; 325 360 326 361 const response = await this.fetchHTTP({ ··· 560 595 ], 561 596 ActionPublisher, 562 597 ); 563 - export { type ActionPublisher }; 598 + export { ActionPublisher };
+117
server/services/analyticsLoggers/ActionExecutionLogger.test.ts
··· 1 + import { type ActionExecutionData } from '../../rule_engine/ActionPublisher.js'; 2 + import { type Dependencies } from '../../iocContainer/index.js'; 3 + import { type AnalyticsSchema } from '../../storage/dataWarehouse/IDataWarehouseAnalytics.js'; 4 + import { type CorrelationId } from '../../utils/correlationIds.js'; 5 + import { jsonParse, type JsonOf } from '../../utils/encoding.js'; 6 + import { ActionExecutionLogger } from './ActionExecutionLogger.js'; 7 + 8 + const asJsonOf = (s: string) => s as JsonOf<unknown>; 9 + 10 + type BulkWrite = Dependencies['DataWarehouseAnalytics']['bulkWrite']; 11 + // Same `as unknown as` pattern used in `test/setupMockedServer.ts` for the 12 + // shared analytics mock — `jest.fn(async () => {})` returns a generic Mock 13 + // that doesn't structurally satisfy the typed `bulkWrite` overload signature. 14 + type BulkWriteMock = jest.MockedFunction<BulkWrite>; 15 + 16 + function makeLogger() { 17 + const bulkWrite = jest.fn(async () => {}) as unknown as BulkWriteMock; 18 + const logger = new ActionExecutionLogger({ bulkWrite }); 19 + return { logger, bulkWrite }; 20 + } 21 + 22 + // `bulkWrite` is generic over `TableName`, so the recorded `rows` arg is a 23 + // union across every analytics table. This logger only writes to 24 + // ACTION_EXECUTIONS, so narrow once for the assertions. 25 + function actionRows( 26 + bulkWrite: BulkWriteMock, 27 + callIndex = 0, 28 + ): readonly AnalyticsSchema['ACTION_EXECUTIONS'][] { 29 + const call = bulkWrite.mock.calls.at(callIndex); 30 + if (!call) throw new Error(`No bulkWrite call at index ${callIndex}`); 31 + return call[1] as readonly AnalyticsSchema['ACTION_EXECUTIONS'][]; 32 + } 33 + 34 + const baseExecution: ActionExecutionData<CorrelationId<'manual-action-run'>> = { 35 + orgId: 'org-1', 36 + action: { id: 'action-1', name: 'Ban User' }, 37 + targetItem: { 38 + itemId: 'item-1', 39 + itemType: { id: 'type-1', kind: 'CONTENT', name: 'Social Post' }, 40 + }, 41 + matchingRules: undefined, 42 + ruleEnvironment: undefined, 43 + correlationId: 44 + 'manual-action-run:abc' as CorrelationId<'manual-action-run'>, 45 + policies: [], 46 + }; 47 + 48 + describe('ActionExecutionLogger', () => { 49 + it('JSON-stringifies parameterValues and writes actorNote straight through', async () => { 50 + const { logger, bulkWrite } = makeLogger(); 51 + 52 + await logger.logActionExecutions({ 53 + executions: [ 54 + { 55 + ...baseExecution, 56 + parameterValues: { num_days: 7, reason: 'spam' }, 57 + actorNote: 'Repeat offender', 58 + }, 59 + ], 60 + failed: false, 61 + }); 62 + 63 + expect(bulkWrite).toHaveBeenCalledTimes(1); 64 + expect(bulkWrite.mock.calls[0]?.[0]).toBe('ACTION_EXECUTIONS'); 65 + const rows = actionRows(bulkWrite); 66 + expect(rows).toHaveLength(1); 67 + expect(jsonParse(asJsonOf(rows[0].parameters))).toEqual({ 68 + num_days: 7, 69 + reason: 'spam', 70 + }); 71 + expect(rows[0].actor_note).toBe('Repeat offender'); 72 + }); 73 + 74 + it("defaults parameters to '{}' and actor_note to undefined when both are absent", async () => { 75 + const { logger, bulkWrite } = makeLogger(); 76 + 77 + await logger.logActionExecutions({ 78 + executions: [baseExecution], 79 + failed: false, 80 + }); 81 + 82 + const row = actionRows(bulkWrite)[0]; 83 + // Mirrors the ClickHouse column's `DEFAULT '{}'` so readers always parse a JSON object. 84 + expect(row.parameters).toBe('{}'); 85 + expect(jsonParse(asJsonOf(row.parameters))).toEqual({}); 86 + expect(row.actor_note).toBeUndefined(); 87 + }); 88 + 89 + it('logs each execution as its own row, preserving per-execution params and notes', async () => { 90 + const { logger, bulkWrite } = makeLogger(); 91 + 92 + await logger.logActionExecutions({ 93 + executions: [ 94 + { 95 + ...baseExecution, 96 + action: { id: 'action-a', name: 'A' }, 97 + parameterValues: { x: 1 }, 98 + actorNote: 'first', 99 + }, 100 + { 101 + ...baseExecution, 102 + action: { id: 'action-b', name: 'B' }, 103 + parameterValues: { y: 2 }, 104 + actorNote: 'second', 105 + }, 106 + ], 107 + failed: false, 108 + }); 109 + 110 + const rows = actionRows(bulkWrite); 111 + expect(rows).toHaveLength(2); 112 + expect(jsonParse(asJsonOf(rows[0].parameters))).toEqual({ x: 1 }); 113 + expect(rows[0].actor_note).toBe('first'); 114 + expect(jsonParse(asJsonOf(rows[1].parameters))).toEqual({ y: 2 }); 115 + expect(rows[1].actor_note).toBe('second'); 116 + }); 117 + });
+12 -4
server/services/analyticsLoggers/ActionExecutionLogger.ts
··· 11 11 getSourceType, 12 12 type CorrelationId, 13 13 } from '../../utils/correlationIds.js'; 14 + import { jsonStringify } from '../../utils/encoding.js'; 14 15 import { safePick } from '../../utils/misc.js'; 15 16 import { getUtcDateOnlyString } from '../../utils/time.js'; 16 17 import { ··· 43 44 policies: Policy[]; 44 45 }; 45 46 47 + // Constructor takes only the slice it actually uses so callers (incl. tests) 48 + // can supply a narrower stub without casting. 49 + type AnalyticsDep = Pick<Dependencies['DataWarehouseAnalytics'], 'bulkWrite'>; 50 + 46 51 class ActionExecutionLogger { 47 - constructor( 48 - private readonly analytics: Dependencies['DataWarehouseAnalytics'], 49 - ) {} 52 + constructor(private readonly analytics: AnalyticsDep) {} 50 53 async logActionExecutions<T extends ActionExecutionCorrelationId>(opts: { 51 54 executions: ActionExecutionData<T>[]; 52 55 failed: boolean; ··· 95 98 policies: data.policies, 96 99 actor_id: data.actorId, 97 100 job_id: data.jobId, 101 + // Always set `parameters` (default `'{}'`) so columnar storage 102 + // doesn't get a mix of NULL and JSON-text values; readers can rely 103 + // on the column being a parseable JSON object. 104 + parameters: jsonStringify(data.parameterValues ?? {}), 105 + actor_note: data.actorNote, 98 106 failed, 99 107 }; 100 108 }), ··· 104 112 } 105 113 106 114 export default inject(['DataWarehouseAnalytics'], ActionExecutionLogger); 107 - export { type ActionExecutionLogger }; 115 + export { ActionExecutionLogger };
+15
server/services/moderationConfigService/index.ts
··· 63 63 makeRuleHasRunningBacktestsError, 64 64 makeLocationBankNameExistsError, 65 65 } from './errors.js'; 66 + 67 + export { 68 + ACTION_PARAMETER_TYPES, 69 + type ActionParameter, 70 + type ActionParameterOption, 71 + type ActionParameterType, 72 + type RawActionParameterInput, 73 + parseStoredParameters, 74 + validateActionParameters, 75 + } from './modules/actionParametersValidation.js'; 76 + export { validateActionParameterValues } from './modules/actionParameterValueValidation.js'; 77 + export { 78 + MAX_ACTOR_NOTE_LENGTH, 79 + validateActorNote, 80 + } from './modules/actorNoteValidation.js';
+167 -2
server/services/moderationConfigService/moderationConfigService.test.ts
··· 678 678 callbackUrlBody: null, 679 679 }); 680 680 681 - // The service create methods don't expose customMrtApiParams, 682 - // so set it via raw Kysely to exercise the read mapping. 681 + // Legacy shape pre-dating the typed parameter spec — set it via raw 682 + // Kysely to verify the read mapping still surfaces older rows 683 + // unchanged for back-compat. 683 684 const params = [ 684 685 { key: 'foo', value: 'bar' }, 685 686 { key: 'baz', value: 'qux' }, ··· 707 708 actionId: action.id, 708 709 }); 709 710 } 711 + }); 712 + 713 + it('round-trips typed parameters through createAction', async () => { 714 + const parameters = [ 715 + { 716 + name: 'num_days_banned', 717 + displayName: 'Days to ban', 718 + type: 'NUMBER', 719 + required: true, 720 + min: 1, 721 + max: 365, 722 + defaultValue: 7, 723 + }, 724 + { 725 + name: 'reason', 726 + displayName: 'Reason', 727 + type: 'SELECT', 728 + required: true, 729 + options: [ 730 + { value: 'spam', label: 'Spam' }, 731 + { value: 'abuse', label: 'Abuse' }, 732 + ], 733 + }, 734 + { 735 + name: 'notify_user', 736 + displayName: 'Notify user', 737 + type: 'BOOLEAN', 738 + required: false, 739 + defaultValue: false, 740 + }, 741 + ]; 742 + 743 + const created = await sutWithPrimary.createAction(dummyOrgId, { 744 + name: faker.random.alphaNumeric(16), 745 + description: null, 746 + type: 'CUSTOM_ACTION', 747 + callbackUrl: 'https://example.com', 748 + callbackUrlHeaders: null, 749 + callbackUrlBody: null, 750 + parameters, 751 + }); 752 + 753 + try { 754 + const [fetched] = await sutWithPrimary.getActions({ 755 + orgId: dummyOrgId, 756 + ids: [created.id], 757 + }); 758 + expect(fetched.actionType).toBe('CUSTOM_ACTION'); 759 + const stored = (fetched as { customMrtApiParams: unknown }) 760 + .customMrtApiParams; 761 + expect(stored).toEqual(parameters); 762 + } finally { 763 + await sutWithPrimary.deleteCustomAction({ 764 + orgId: dummyOrgId, 765 + actionId: created.id, 766 + }); 767 + } 768 + }); 769 + 770 + it('rejects invalid parameters at create time', async () => { 771 + await expect( 772 + sutWithPrimary.createAction(dummyOrgId, { 773 + name: faker.random.alphaNumeric(16), 774 + description: null, 775 + type: 'CUSTOM_ACTION', 776 + callbackUrl: 'https://example.com', 777 + callbackUrlHeaders: null, 778 + callbackUrlBody: null, 779 + parameters: [ 780 + { 781 + name: 'invalid name with spaces', 782 + displayName: 'X', 783 + type: 'STRING', 784 + required: false, 785 + }, 786 + ], 787 + }), 788 + ).rejects.toMatchObject({ status: 400 }); 710 789 }); 711 790 }); 712 791 }); ··· 827 906 } finally { 828 907 await otherOrg.cleanup(); 829 908 } 909 + }, 910 + ); 911 + 912 + testWithAction( 913 + 'updates parameters when patch.parameters is supplied', 914 + async ({ action }) => { 915 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 916 + actionId: action.id, 917 + patch: { 918 + parameters: [ 919 + { 920 + name: 'foo', 921 + displayName: 'Foo', 922 + type: 'STRING', 923 + required: false, 924 + }, 925 + ], 926 + }, 927 + }); 928 + 929 + const [afterSet] = await sutWithPrimary.getActions({ 930 + orgId: dummyOrgId, 931 + ids: [action.id], 932 + }); 933 + expect( 934 + (afterSet as { customMrtApiParams: unknown }).customMrtApiParams, 935 + ).toEqual([ 936 + { 937 + name: 'foo', 938 + displayName: 'Foo', 939 + type: 'STRING', 940 + required: false, 941 + }, 942 + ]); 943 + 944 + // Passing `[]` should clear, not leave the existing list in place. 945 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 946 + actionId: action.id, 947 + patch: { parameters: [] }, 948 + }); 949 + const [afterClear] = await sutWithPrimary.getActions({ 950 + orgId: dummyOrgId, 951 + ids: [action.id], 952 + }); 953 + expect( 954 + (afterClear as { customMrtApiParams: unknown }) 955 + .customMrtApiParams, 956 + ).toBeNull(); 957 + }, 958 + ); 959 + 960 + testWithAction( 961 + 'leaves parameters unchanged when patch.parameters is omitted', 962 + async ({ action }) => { 963 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 964 + actionId: action.id, 965 + patch: { 966 + parameters: [ 967 + { 968 + name: 'foo', 969 + displayName: 'Foo', 970 + type: 'STRING', 971 + required: false, 972 + }, 973 + ], 974 + }, 975 + }); 976 + await sutWithPrimary.updateCustomAction(dummyOrgId, { 977 + actionId: action.id, 978 + patch: { description: 'after' }, 979 + }); 980 + const [fetched] = await sutWithPrimary.getActions({ 981 + orgId: dummyOrgId, 982 + ids: [action.id], 983 + }); 984 + expect(fetched.description).toBe('after'); 985 + expect( 986 + (fetched as { customMrtApiParams: unknown }).customMrtApiParams, 987 + ).toEqual([ 988 + { 989 + name: 'foo', 990 + displayName: 'Foo', 991 + type: 'STRING', 992 + required: false, 993 + }, 994 + ]); 830 995 }, 831 996 ); 832 997
+6 -26
server/services/moderationConfigService/moderationConfigService.ts
··· 1 1 import { type Kysely } from 'kysely'; 2 2 import _ from 'lodash'; 3 - import { type JsonObject, type ReadonlyDeep } from 'type-fest'; 3 + import { type ReadonlyDeep } from 'type-fest'; 4 4 5 5 import { type ConsumerDirectives } from '../../lib/cache/index.js'; 6 6 import type { Invoker } from '../../models/types/permissioning.js'; ··· 247 247 return this.itemTypeOps.getItemTypesForRule(opts); 248 248 } 249 249 250 + // TODO: support other action types? Need to figure out the relationship 251 + // between activating various org settings (e.g., enabling MRT or NCMEC 252 + // reporting) and this moderationConfigService. 250 253 async createAction( 251 254 orgId: string, 252 - input: { 253 - name: string; 254 - description: string | null; 255 - // TODO: support other types? Need to figure out relationship between 256 - // activating various org settings (e.g., to enable MRT or NCMEC reporting) 257 - // and this moderationConfigService. 258 - type: 'CUSTOM_ACTION'; 259 - callbackUrl: string; 260 - callbackUrlHeaders: JsonObject | null; 261 - callbackUrlBody: JsonObject | null; 262 - applyUserStrikes?: boolean; 263 - itemTypeIds?: readonly string[]; 264 - }, 255 + input: Parameters<ActionOperations['createAction']>[1], 265 256 ): Promise<CustomAction> { 266 257 return this.actionOps.createAction(orgId, input); 267 258 } 268 259 269 260 async updateCustomAction( 270 261 orgId: string, 271 - opts: { 272 - actionId: string; 273 - patch: { 274 - name?: string; 275 - description?: string | null; 276 - callbackUrl?: string; 277 - callbackUrlHeaders?: JsonObject | null; 278 - callbackUrlBody?: JsonObject | null; 279 - applyUserStrikes?: boolean; 280 - }; 281 - itemTypeIds?: readonly string[] | undefined; 282 - }, 262 + opts: Omit<Parameters<ActionOperations['updateCustomAction']>[0], 'orgId'>, 283 263 ): Promise<CustomAction> { 284 264 return this.actionOps.updateCustomAction({ orgId, ...opts }); 285 265 }
+22
server/services/moderationConfigService/modules/ActionOperations.ts
··· 17 17 import { type ModerationConfigServicePg } from '../dbTypes.js'; 18 18 import { type Action, type CustomAction } from '../index.js'; 19 19 import { type ItemTypeKind } from '../types/itemTypes.js'; 20 + import { 21 + type RawActionParameterInput, 22 + serializeParameters, 23 + validateActionParameters, 24 + } from './actionParametersValidation.js'; 20 25 21 26 function assertCustomAction(action: Action): asserts action is CustomAction { 22 27 if (action.actionType !== 'CUSTOM_ACTION') { ··· 118 123 callbackUrlBody: JsonObject | null; 119 124 applyUserStrikes?: boolean; 120 125 itemTypeIds?: readonly string[]; 126 + // AJV narrows this to `readonly ActionParameter[]` and rejects nulls / 127 + // unknown fields; the GraphQL input shape is wider (nullable optionals) 128 + // so we accept arbitrary property bags here. 129 + parameters?: readonly RawActionParameterInput[] | null; 121 130 }, 122 131 ): Promise<CustomAction> { 132 + const parameters = validateActionParameters(input.parameters ?? null); 133 + 123 134 return this.transactionWithRetry(async (trx) => { 124 135 try { 125 136 const query = trx ··· 135 146 callback_url_body: input.callbackUrlBody, 136 147 penalty: 'NONE', 137 148 apply_user_strikes: input.applyUserStrikes ?? false, 149 + custom_mrt_api_params: serializeParameters(parameters), 138 150 updated_at: new Date(), 139 151 }) 140 152 .returning(actionDbSelection); ··· 226 238 callbackUrlHeaders?: JsonObject | null; 227 239 callbackUrlBody?: JsonObject | null; 228 240 applyUserStrikes?: boolean; 241 + // `undefined` = leave unchanged. Pass `[]` to clear all parameters. 242 + parameters?: readonly RawActionParameterInput[] | null; 229 243 }; 230 244 itemTypeIds?: readonly string[] | undefined; 231 245 }): Promise<CustomAction> { 232 246 const { orgId, actionId, patch, itemTypeIds } = opts; 247 + const validatedParameters = 248 + patch.parameters === undefined 249 + ? undefined 250 + : validateActionParameters(patch.parameters); 233 251 return this.transactionWithRetry(async (trx) => { 234 252 const existing = (await trx 235 253 .selectFrom('public.actions') ··· 250 268 callback_url_headers: patch.callbackUrlHeaders, 251 269 callback_url_body: patch.callbackUrlBody, 252 270 apply_user_strikes: patch.applyUserStrikes, 271 + custom_mrt_api_params: 272 + validatedParameters === undefined 273 + ? undefined 274 + : serializeParameters(validatedParameters), 253 275 }); 254 276 const hasUserFields = Object.keys(setPayload).length > 0; 255 277 const touchesJunction = itemTypeIds !== undefined;
+151
server/services/moderationConfigService/modules/actionParameterValueValidation.ts
··· 1 + import { makeBadRequestError } from '../../../utils/errors.js'; 2 + import { assertUnreachable } from '../../../utils/misc.js'; 3 + 4 + import { type ActionParameter } from './actionParametersValidation.js'; 5 + 6 + function makeInvalidParameterValueError(detail: string) { 7 + return makeBadRequestError('Invalid action parameter value', { 8 + detail, 9 + shouldErrorSpan: false, 10 + }); 11 + } 12 + 13 + /** 14 + * Validate moderator-supplied runtime values against an action's parameter 15 + * spec. Used at execution time on every code path that publishes an action 16 + * (GraphQL `bulkExecuteActions`, REST `submitAction`, MRT decision submit). 17 + * 18 + * Behavior: 19 + * - Required parameters must be present (or have a `defaultValue`); missing 20 + * ones throw. Empty-string (STRING/SELECT) and `[]` (MULTISELECT) also 21 + * count as missing for required parameters; `0` and `false` do not. 22 + * - Each value must match its declared `type` (and `min`/`max`/`maxLength` 23 + * /`options` when applicable). 24 + * - Unknown keys (not declared in the spec) are rejected to avoid silently 25 + * smuggling extra fields into the webhook payload. 26 + * 27 + * Returns a fresh object containing only declared keys with coerced values. 28 + * Defaults are applied for omitted optional parameters that have a 29 + * `defaultValue` set; omitted optionals without a default are dropped (not 30 + * sent as `undefined`). 31 + * 32 + * Accepts `unknown` for `rawValues` so untrusted REST/GraphQL bodies can be 33 + * passed in directly without callers pre-narrowing. 34 + */ 35 + export function validateActionParameterValues( 36 + spec: readonly ActionParameter[], 37 + rawValues: unknown, 38 + ): Record<string, unknown> { 39 + if ( 40 + rawValues != null && 41 + (typeof rawValues !== 'object' || Array.isArray(rawValues)) 42 + ) { 43 + throw makeInvalidParameterValueError( 44 + 'parameters must be a plain object keyed by parameter name', 45 + ); 46 + } 47 + const values: Readonly<Record<string, unknown>> = 48 + (rawValues as Readonly<Record<string, unknown>> | null | undefined) ?? {}; 49 + const declaredKeys = new Set(spec.map((p) => p.name)); 50 + 51 + for (const key of Object.keys(values)) { 52 + if (!declaredKeys.has(key)) { 53 + throw makeInvalidParameterValueError(`Unknown parameter "${key}"`); 54 + } 55 + } 56 + 57 + const out: Record<string, unknown> = {}; 58 + for (const param of spec) { 59 + const supplied = Object.prototype.hasOwnProperty.call(values, param.name) 60 + ? values[param.name] 61 + : undefined; 62 + const effective = supplied !== undefined ? supplied : param.defaultValue; 63 + 64 + if (isMissingForRequired(param, effective)) { 65 + if (param.required) { 66 + throw makeInvalidParameterValueError( 67 + `Parameter "${param.name}" is required`, 68 + ); 69 + } 70 + continue; 71 + } 72 + 73 + out[param.name] = coerceValueOrThrow(param, effective); 74 + } 75 + return out; 76 + } 77 + 78 + // Treats null/undefined as missing for any type. Additionally treats 79 + // whitespace-only strings as missing for STRING/SELECT, and `[]` as missing 80 + // for MULTISELECT — these would otherwise pass the required check while 81 + // being semantically empty. NUMBER 0 and BOOLEAN false remain valid. 82 + function isMissingForRequired( 83 + param: ActionParameter, 84 + value: unknown, 85 + ): boolean { 86 + if (value === undefined || value === null) return true; 87 + switch (param.type) { 88 + case 'STRING': 89 + case 'SELECT': 90 + return typeof value === 'string' && value.trim() === ''; 91 + case 'MULTISELECT': 92 + return Array.isArray(value) && value.length === 0; 93 + case 'NUMBER': 94 + case 'BOOLEAN': 95 + return false; 96 + default: 97 + return assertUnreachable(param.type); 98 + } 99 + } 100 + 101 + function coerceValueOrThrow(param: ActionParameter, value: unknown): unknown { 102 + const reject = (msg: string): never => { 103 + throw makeInvalidParameterValueError(`Parameter "${param.name}": ${msg}`); 104 + }; 105 + switch (param.type) { 106 + case 'STRING': { 107 + if (typeof value !== 'string') return reject('expected a string'); 108 + if (param.maxLength !== undefined && value.length > param.maxLength) { 109 + return reject(`exceeds maxLength of ${param.maxLength}`); 110 + } 111 + return value; 112 + } 113 + case 'NUMBER': { 114 + if (typeof value !== 'number' || !Number.isFinite(value)) { 115 + return reject('expected a finite number'); 116 + } 117 + if (param.min !== undefined && value < param.min) { 118 + return reject(`below min of ${param.min}`); 119 + } 120 + if (param.max !== undefined && value > param.max) { 121 + return reject(`above max of ${param.max}`); 122 + } 123 + return value; 124 + } 125 + case 'BOOLEAN': { 126 + if (typeof value !== 'boolean') return reject('expected a boolean'); 127 + return value; 128 + } 129 + case 'SELECT': { 130 + const allowed = (param.options ?? []).map((o) => o.value); 131 + if (typeof value !== 'string' || !allowed.includes(value)) { 132 + return reject('not one of the allowed option values'); 133 + } 134 + return value; 135 + } 136 + case 'MULTISELECT': { 137 + const allowed = new Set((param.options ?? []).map((o) => o.value)); 138 + if ( 139 + !Array.isArray(value) || 140 + !value.every((v) => typeof v === 'string' && allowed.has(v)) 141 + ) { 142 + return reject('expected an array of allowed option values'); 143 + } 144 + // Defensive copy; downstream consumers shouldn't share array refs with 145 + // the caller's request body. 146 + return [...value]; 147 + } 148 + default: 149 + return assertUnreachable(param.type); 150 + } 151 + }
+479
server/services/moderationConfigService/modules/actionParametersValidation.test.ts
··· 1 + import { validateActionParameterValues } from './actionParameterValueValidation.js'; 2 + import { 3 + parseStoredParameters, 4 + validateActionParameters, 5 + } from './actionParametersValidation.js'; 6 + 7 + describe('validateActionParameters', () => { 8 + it('returns an empty array for null/undefined/empty', () => { 9 + expect(validateActionParameters(null)).toEqual([]); 10 + expect(validateActionParameters(undefined)).toEqual([]); 11 + expect(validateActionParameters([])).toEqual([]); 12 + }); 13 + 14 + it('accepts a well-formed STRING parameter', () => { 15 + const params = validateActionParameters([ 16 + { 17 + name: 'reason', 18 + displayName: 'Reason', 19 + type: 'STRING', 20 + required: true, 21 + maxLength: 500, 22 + }, 23 + ]); 24 + expect(params).toHaveLength(1); 25 + expect(params[0]?.type).toBe('STRING'); 26 + }); 27 + 28 + it('accepts NUMBER with min/max and default in range', () => { 29 + expect(() => 30 + validateActionParameters([ 31 + { 32 + name: 'days', 33 + displayName: 'Days', 34 + type: 'NUMBER', 35 + required: false, 36 + min: 1, 37 + max: 30, 38 + defaultValue: 7, 39 + }, 40 + ]), 41 + ).not.toThrow(); 42 + }); 43 + 44 + it('rejects NUMBER with default below min', () => { 45 + expect(() => 46 + validateActionParameters([ 47 + { 48 + name: 'days', 49 + displayName: 'Days', 50 + type: 'NUMBER', 51 + required: false, 52 + min: 5, 53 + defaultValue: 1, 54 + }, 55 + ]), 56 + ).toThrow(/below min/); 57 + }); 58 + 59 + it('rejects NUMBER with min > max', () => { 60 + expect(() => 61 + validateActionParameters([ 62 + { 63 + name: 'days', 64 + displayName: 'Days', 65 + type: 'NUMBER', 66 + required: false, 67 + min: 10, 68 + max: 5, 69 + }, 70 + ]), 71 + ).toThrow(/min.*<=.*max/); 72 + }); 73 + 74 + it('rejects names with whitespace, quotes, or brackets', () => { 75 + for (const name of ['has spaces', 'q"uote', 'bracket[0]', 'paren(x)']) { 76 + expect(() => 77 + validateActionParameters([ 78 + { name, displayName: 'Bad', type: 'STRING', required: false }, 79 + ]), 80 + ).toThrow(); 81 + } 82 + }); 83 + 84 + it('accepts snake_case, kebab-case, and dotted names', () => { 85 + for (const name of ['ban_duration', 'ban-duration', 'org.user.id', 'a.b-c_1']) { 86 + expect(() => 87 + validateActionParameters([ 88 + { name, displayName: 'OK', type: 'STRING', required: false }, 89 + ]), 90 + ).not.toThrow(); 91 + } 92 + }); 93 + 94 + it('rejects duplicate names', () => { 95 + expect(() => 96 + validateActionParameters([ 97 + { name: 'a', displayName: 'A', type: 'STRING', required: false }, 98 + { name: 'a', displayName: 'A2', type: 'STRING', required: false }, 99 + ]), 100 + ).toThrow(/duplicated/); 101 + }); 102 + 103 + it('requires options for SELECT and MULTISELECT', () => { 104 + expect(() => 105 + validateActionParameters([ 106 + { name: 'x', displayName: 'X', type: 'SELECT', required: false }, 107 + ]), 108 + ).toThrow(/required for SELECT/); 109 + expect(() => 110 + validateActionParameters([ 111 + { 112 + name: 'x', 113 + displayName: 'X', 114 + type: 'MULTISELECT', 115 + required: false, 116 + }, 117 + ]), 118 + ).toThrow(/required for MULTISELECT/); 119 + }); 120 + 121 + it('rejects SELECT default that is not in options', () => { 122 + expect(() => 123 + validateActionParameters([ 124 + { 125 + name: 'reason', 126 + displayName: 'Reason', 127 + type: 'SELECT', 128 + required: false, 129 + options: [{ value: 'spam', label: 'Spam' }], 130 + defaultValue: 'abuse', 131 + }, 132 + ]), 133 + ).toThrow(/option values/); 134 + }); 135 + 136 + it('accepts MULTISELECT default as an array of option values', () => { 137 + expect(() => 138 + validateActionParameters([ 139 + { 140 + name: 'tags', 141 + displayName: 'Tags', 142 + type: 'MULTISELECT', 143 + required: false, 144 + options: [ 145 + { value: 'a', label: 'A' }, 146 + { value: 'b', label: 'B' }, 147 + ], 148 + defaultValue: ['a', 'b'], 149 + }, 150 + ]), 151 + ).not.toThrow(); 152 + }); 153 + 154 + it('rejects type-incompatible defaultValue', () => { 155 + expect(() => 156 + validateActionParameters([ 157 + { 158 + name: 'x', 159 + displayName: 'X', 160 + type: 'STRING', 161 + required: false, 162 + defaultValue: 123, 163 + }, 164 + ]), 165 + ).toThrow(/string for STRING/); 166 + expect(() => 167 + validateActionParameters([ 168 + { 169 + name: 'x', 170 + displayName: 'X', 171 + type: 'BOOLEAN', 172 + required: false, 173 + defaultValue: 'true', 174 + }, 175 + ]), 176 + ).toThrow(/boolean for BOOLEAN/); 177 + }); 178 + 179 + it('rejects unknown top-level fields (additionalProperties)', () => { 180 + expect(() => 181 + validateActionParameters([ 182 + { 183 + name: 'x', 184 + displayName: 'X', 185 + type: 'STRING', 186 + required: false, 187 + surprise: 'value', 188 + }, 189 + ]), 190 + ).toThrow(); 191 + }); 192 + 193 + describe('rejects "empty" defaultValue when the parameter is required', () => { 194 + it('STRING with empty default', () => { 195 + expect(() => 196 + validateActionParameters([ 197 + { 198 + name: 'x', 199 + displayName: 'X', 200 + type: 'STRING', 201 + required: true, 202 + defaultValue: '', 203 + }, 204 + ]), 205 + ).toThrow(/cannot be empty when the parameter is required/); 206 + }); 207 + 208 + it('STRING with whitespace-only default', () => { 209 + expect(() => 210 + validateActionParameters([ 211 + { 212 + name: 'x', 213 + displayName: 'X', 214 + type: 'STRING', 215 + required: true, 216 + defaultValue: ' ', 217 + }, 218 + ]), 219 + ).toThrow(/cannot be empty when the parameter is required/); 220 + }); 221 + 222 + it('MULTISELECT with empty array default', () => { 223 + expect(() => 224 + validateActionParameters([ 225 + { 226 + name: 'tags', 227 + displayName: 'Tags', 228 + type: 'MULTISELECT', 229 + required: true, 230 + options: [{ value: 'a', label: 'A' }], 231 + defaultValue: [], 232 + }, 233 + ]), 234 + ).toThrow(/cannot be empty when the parameter is required/); 235 + }); 236 + }); 237 + }); 238 + 239 + describe('parseStoredParameters', () => { 240 + it('returns [] for null/undefined/empty/non-arrays', () => { 241 + expect(parseStoredParameters(null)).toEqual([]); 242 + expect(parseStoredParameters(undefined)).toEqual([]); 243 + expect(parseStoredParameters([])).toEqual([]); 244 + expect(parseStoredParameters('not an array')).toEqual([]); 245 + }); 246 + 247 + it('round-trips a well-formed list', () => { 248 + const stored = [ 249 + { 250 + name: 'reason', 251 + displayName: 'Reason', 252 + type: 'SELECT', 253 + required: true, 254 + options: [{ value: 'spam', label: 'Spam' }], 255 + }, 256 + ]; 257 + const parsed = parseStoredParameters(stored); 258 + expect(parsed).toHaveLength(1); 259 + expect(parsed[0]?.type).toBe('SELECT'); 260 + expect(parsed[0]?.options).toEqual([{ value: 'spam', label: 'Spam' }]); 261 + }); 262 + 263 + it('skips entries with unknown type or missing required keys (defensive)', () => { 264 + const stored = [ 265 + // Valid 266 + { name: 'a', displayName: 'A', type: 'STRING', required: false }, 267 + // Missing displayName 268 + { name: 'b', type: 'STRING', required: false }, 269 + // Unknown type 270 + { name: 'c', displayName: 'C', type: 'WHATEVER', required: false }, 271 + // Not an object 272 + 'garbage', 273 + null, 274 + ]; 275 + expect(parseStoredParameters(stored)).toHaveLength(1); 276 + }); 277 + }); 278 + 279 + describe('validateActionParameterValues', () => { 280 + const spec = [ 281 + { 282 + name: 'days', 283 + displayName: 'Days', 284 + type: 'NUMBER' as const, 285 + required: true, 286 + min: 1, 287 + max: 365, 288 + }, 289 + { 290 + name: 'reason', 291 + displayName: 'Reason', 292 + type: 'SELECT' as const, 293 + required: true, 294 + options: [ 295 + { value: 'spam', label: 'Spam' }, 296 + { value: 'abuse', label: 'Abuse' }, 297 + ], 298 + }, 299 + { 300 + name: 'silent', 301 + displayName: 'Silent', 302 + type: 'BOOLEAN' as const, 303 + required: false, 304 + defaultValue: false, 305 + }, 306 + ]; 307 + 308 + it('accepts a complete, well-typed value map', () => { 309 + const out = validateActionParameterValues(spec, { 310 + days: 7, 311 + reason: 'spam', 312 + silent: true, 313 + }); 314 + expect(out).toEqual({ days: 7, reason: 'spam', silent: true }); 315 + }); 316 + 317 + it('applies defaults for omitted optional parameters', () => { 318 + const out = validateActionParameterValues(spec, { 319 + days: 1, 320 + reason: 'abuse', 321 + }); 322 + expect(out.silent).toBe(false); 323 + }); 324 + 325 + it('rejects missing required parameters', () => { 326 + expect(() => 327 + validateActionParameterValues(spec, { reason: 'spam' }), 328 + ).toThrow(/required/); 329 + }); 330 + 331 + it('rejects values that violate per-type rules', () => { 332 + expect(() => 333 + validateActionParameterValues(spec, { days: 9999, reason: 'spam' }), 334 + ).toThrow(/above max/); 335 + expect(() => 336 + validateActionParameterValues(spec, { days: 1, reason: 'unknown' }), 337 + ).toThrow(/option values/); 338 + }); 339 + 340 + it('rejects unknown keys not declared in the spec', () => { 341 + expect(() => 342 + validateActionParameterValues(spec, { 343 + days: 1, 344 + reason: 'spam', 345 + sneaky: 'value', 346 + }), 347 + ).toThrow(/Unknown parameter/); 348 + }); 349 + 350 + it('returns an empty object for empty spec and empty values', () => { 351 + expect(validateActionParameterValues([], {})).toEqual({}); 352 + expect(validateActionParameterValues([], null)).toEqual({}); 353 + }); 354 + 355 + it('makes a defensive copy of MULTISELECT arrays', () => { 356 + const multi = [ 357 + { 358 + name: 'tags', 359 + displayName: 'Tags', 360 + type: 'MULTISELECT' as const, 361 + required: true, 362 + options: [ 363 + { value: 'a', label: 'A' }, 364 + { value: 'b', label: 'B' }, 365 + ], 366 + }, 367 + ]; 368 + const input = ['a', 'b']; 369 + const out = validateActionParameterValues(multi, { tags: input }); 370 + expect(out.tags).toEqual(['a', 'b']); 371 + expect(out.tags).not.toBe(input); 372 + }); 373 + 374 + describe('strict required-field semantics', () => { 375 + const stringSpec = [ 376 + { 377 + name: 'reason', 378 + displayName: 'Reason', 379 + type: 'STRING' as const, 380 + required: true, 381 + }, 382 + ]; 383 + const selectSpec = [ 384 + { 385 + name: 'reason', 386 + displayName: 'Reason', 387 + type: 'SELECT' as const, 388 + required: true, 389 + options: [{ value: 'spam', label: 'Spam' }], 390 + }, 391 + ]; 392 + const multiSpec = [ 393 + { 394 + name: 'tags', 395 + displayName: 'Tags', 396 + type: 'MULTISELECT' as const, 397 + required: true, 398 + options: [{ value: 'a', label: 'A' }], 399 + }, 400 + ]; 401 + 402 + it('treats empty STRING as missing for required parameters', () => { 403 + expect(() => 404 + validateActionParameterValues(stringSpec, { reason: '' }), 405 + ).toThrow(/required/); 406 + }); 407 + 408 + it('treats whitespace-only STRING as missing for required parameters', () => { 409 + expect(() => 410 + validateActionParameterValues(stringSpec, { reason: ' ' }), 411 + ).toThrow(/required/); 412 + }); 413 + 414 + it('treats empty MULTISELECT array as missing for required parameters', () => { 415 + expect(() => 416 + validateActionParameterValues(multiSpec, { tags: [] }), 417 + ).toThrow(/required/); 418 + }); 419 + 420 + it('rejects null for required parameters', () => { 421 + expect(() => 422 + validateActionParameterValues(stringSpec, { reason: null }), 423 + ).toThrow(/required/); 424 + }); 425 + 426 + it('rejects empty SELECT string against the option allowlist', () => { 427 + expect(() => 428 + validateActionParameterValues(selectSpec, { reason: '' }), 429 + ).toThrow(/required/); 430 + }); 431 + 432 + it('keeps NUMBER 0 valid for required parameters', () => { 433 + const spec = [ 434 + { 435 + name: 'count', 436 + displayName: 'Count', 437 + type: 'NUMBER' as const, 438 + required: true, 439 + }, 440 + ]; 441 + expect(validateActionParameterValues(spec, { count: 0 })).toEqual({ 442 + count: 0, 443 + }); 444 + }); 445 + 446 + it('keeps BOOLEAN false valid for required parameters', () => { 447 + const spec = [ 448 + { 449 + name: 'flag', 450 + displayName: 'Flag', 451 + type: 'BOOLEAN' as const, 452 + required: true, 453 + }, 454 + ]; 455 + expect(validateActionParameterValues(spec, { flag: false })).toEqual({ 456 + flag: false, 457 + }); 458 + }); 459 + }); 460 + 461 + describe('top-level shape validation', () => { 462 + it('rejects an array as the parameter map', () => { 463 + expect(() => validateActionParameterValues([], [])).toThrow( 464 + /plain object/, 465 + ); 466 + }); 467 + 468 + it('rejects a primitive as the parameter map', () => { 469 + expect(() => validateActionParameterValues([], 'string')).toThrow( 470 + /plain object/, 471 + ); 472 + }); 473 + 474 + it('accepts null and undefined (treated as no values supplied)', () => { 475 + expect(validateActionParameterValues([], null)).toEqual({}); 476 + expect(validateActionParameterValues([], undefined)).toEqual({}); 477 + }); 478 + }); 479 + });
+398
server/services/moderationConfigService/modules/actionParametersValidation.ts
··· 1 + import _Ajv, { type ErrorObject } from 'ajv-draft-04'; 2 + import { type JsonValue } from 'type-fest'; 3 + 4 + import { makeBadRequestError } from '../../../utils/errors.js'; 5 + import { assertUnreachable } from '../../../utils/misc.js'; 6 + 7 + // `ajv-draft-04` is CJS. 8 + const Ajv = _Ajv as unknown as typeof _Ajv.default; 9 + 10 + export const ACTION_PARAMETER_TYPES = [ 11 + 'STRING', 12 + 'NUMBER', 13 + 'BOOLEAN', 14 + 'SELECT', 15 + 'MULTISELECT', 16 + ] as const; 17 + export type ActionParameterType = (typeof ACTION_PARAMETER_TYPES)[number]; 18 + 19 + export type ActionParameterOption = { 20 + value: string; 21 + label: string; 22 + }; 23 + 24 + export type ActionParameter = { 25 + name: string; 26 + displayName: string; 27 + description?: string; 28 + type: ActionParameterType; 29 + required: boolean; 30 + options?: readonly ActionParameterOption[]; 31 + min?: number; 32 + max?: number; 33 + maxLength?: number; 34 + defaultValue?: unknown; 35 + }; 36 + 37 + /** 38 + * Pre-validation shape — what GraphQL/REST callers hand us before AJV runs. 39 + * Looser than `ActionParameter` (any string-keyed object); we use this in 40 + * service-layer signatures so the type makes the "needs validation" boundary 41 + * obvious without forcing callers to pre-narrow null vs undefined. 42 + */ 43 + export type RawActionParameterInput = Readonly<Record<string, unknown>>; 44 + 45 + // Names become keys in the webhook payload's `body.custom`. We allow letters, 46 + // digits, `_`, `-`, and `.` so consumers can use snake_case, kebab-case, or 47 + // dotted namespacing; whitespace, quotes, and brackets are rejected because 48 + // they break dotted access in most languages and need escaping in URLs/logs. 49 + const PARAMETER_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$'; 50 + 51 + const optionSchema = { 52 + type: 'object', 53 + additionalProperties: false, 54 + required: ['value', 'label'], 55 + properties: { 56 + value: { type: 'string', minLength: 1, maxLength: 200 }, 57 + label: { type: 'string', minLength: 1, maxLength: 200 }, 58 + }, 59 + } as const; 60 + 61 + // Structural shape only. Per-type rules (options required for SELECT, default 62 + // matches type, min<=max) live in `validatePerTypeRules` because expressing 63 + // them in JSON Schema draft-04 is verbose and harder to read. 64 + const parameterSchema = { 65 + type: 'object', 66 + additionalProperties: false, 67 + required: ['name', 'displayName', 'type', 'required'], 68 + properties: { 69 + name: { 70 + type: 'string', 71 + minLength: 1, 72 + maxLength: 64, 73 + pattern: PARAMETER_NAME_PATTERN, 74 + }, 75 + displayName: { type: 'string', minLength: 1, maxLength: 200 }, 76 + description: { type: 'string', maxLength: 2000 }, 77 + type: { enum: [...ACTION_PARAMETER_TYPES] }, 78 + required: { type: 'boolean' }, 79 + options: { type: 'array', items: optionSchema, minItems: 1, maxItems: 100 }, 80 + min: { type: 'number' }, 81 + max: { type: 'number' }, 82 + maxLength: { type: 'integer', minimum: 1, maximum: 100000 }, 83 + defaultValue: {}, 84 + }, 85 + } as const; 86 + 87 + const parameterListSchema = { 88 + type: 'array', 89 + items: parameterSchema, 90 + maxItems: 50, 91 + } as const; 92 + 93 + const ajv = new Ajv({ allErrors: true, strictSchema: true }); 94 + const validateStructure = ajv.compile(parameterListSchema); 95 + 96 + /** 97 + * Throw a `CoopError` if `parameters` is not a valid action parameter list. 98 + * Narrows `unknown` to `ActionParameter[]` on success. 99 + */ 100 + export function validateActionParameters( 101 + parameters: unknown, 102 + ): readonly ActionParameter[] { 103 + if (parameters == null) { 104 + return []; 105 + } 106 + 107 + if (!validateStructure(parameters)) { 108 + throw makeInvalidParameterError(formatAjvErrors(validateStructure.errors)); 109 + } 110 + 111 + const list = parameters as ActionParameter[]; 112 + 113 + const seenNames = new Set<string>(); 114 + for (const [index, param] of list.entries()) { 115 + if (seenNames.has(param.name)) { 116 + throw makeInvalidParameterError( 117 + `parameters[${index}].name "${param.name}" is duplicated`, 118 + ); 119 + } 120 + seenNames.add(param.name); 121 + 122 + validatePerTypeRules(param, index); 123 + } 124 + 125 + return list; 126 + } 127 + 128 + function validatePerTypeRules(param: ActionParameter, index: number): void { 129 + switch (param.type) { 130 + case 'STRING': 131 + validateStringRules(param, index); 132 + return; 133 + case 'NUMBER': 134 + validateNumberRules(param, index); 135 + return; 136 + case 'BOOLEAN': 137 + validateBooleanRules(param, index); 138 + return; 139 + case 'SELECT': 140 + case 'MULTISELECT': 141 + validateSelectRules(param, index); 142 + return; 143 + default: 144 + // AJV's `enum` keyword has already rejected unknown `type` values; this 145 + // branch only exists to satisfy the exhaustiveness check. 146 + assertUnreachable(param.type); 147 + } 148 + } 149 + 150 + const at = (index: number, suffix: string) => 151 + `parameters[${index}].${suffix}`; 152 + 153 + function validateStringRules(param: ActionParameter, index: number): void { 154 + if (param.options !== undefined) { 155 + throw makeInvalidParameterError( 156 + `${at(index, 'options')} is not allowed for STRING parameters`, 157 + ); 158 + } 159 + if (param.min !== undefined || param.max !== undefined) { 160 + throw makeInvalidParameterError( 161 + `${at(index, 'min/max')} is not allowed for STRING (use maxLength)`, 162 + ); 163 + } 164 + if ( 165 + param.defaultValue !== undefined && 166 + typeof param.defaultValue !== 'string' 167 + ) { 168 + throw makeInvalidParameterError( 169 + `${at(index, 'defaultValue')} must be a string for STRING parameters`, 170 + ); 171 + } 172 + if ( 173 + param.maxLength !== undefined && 174 + typeof param.defaultValue === 'string' && 175 + param.defaultValue.length > param.maxLength 176 + ) { 177 + throw makeInvalidParameterError( 178 + `${at(index, 'defaultValue')} exceeds maxLength`, 179 + ); 180 + } 181 + // An empty default on a required field would silently pass the runtime 182 + // required-check. Reject at authoring time so the spec is internally 183 + // consistent. 184 + if ( 185 + param.required && 186 + typeof param.defaultValue === 'string' && 187 + param.defaultValue.trim() === '' 188 + ) { 189 + throw makeInvalidParameterError( 190 + `${at(index, 'defaultValue')} cannot be empty when the parameter is required`, 191 + ); 192 + } 193 + } 194 + 195 + function validateNumberRules(param: ActionParameter, index: number): void { 196 + if (param.options !== undefined) { 197 + throw makeInvalidParameterError( 198 + `${at(index, 'options')} is not allowed for NUMBER parameters`, 199 + ); 200 + } 201 + if (param.maxLength !== undefined) { 202 + throw makeInvalidParameterError( 203 + `${at(index, 'maxLength')} is not allowed for NUMBER parameters`, 204 + ); 205 + } 206 + if ( 207 + param.min !== undefined && 208 + param.max !== undefined && 209 + param.min > param.max 210 + ) { 211 + throw makeInvalidParameterError(`${at(index, 'min')} must be <= max`); 212 + } 213 + if (param.defaultValue === undefined) return; 214 + if ( 215 + typeof param.defaultValue !== 'number' || 216 + Number.isNaN(param.defaultValue) 217 + ) { 218 + throw makeInvalidParameterError( 219 + `${at(index, 'defaultValue')} must be a number for NUMBER parameters`, 220 + ); 221 + } 222 + if (param.min !== undefined && param.defaultValue < param.min) { 223 + throw makeInvalidParameterError( 224 + `${at(index, 'defaultValue')} is below min`, 225 + ); 226 + } 227 + if (param.max !== undefined && param.defaultValue > param.max) { 228 + throw makeInvalidParameterError( 229 + `${at(index, 'defaultValue')} is above max`, 230 + ); 231 + } 232 + } 233 + 234 + function validateBooleanRules(param: ActionParameter, index: number): void { 235 + if ( 236 + param.options !== undefined || 237 + param.min !== undefined || 238 + param.max !== undefined || 239 + param.maxLength !== undefined 240 + ) { 241 + throw makeInvalidParameterError( 242 + `${at(index, '')} only "defaultValue" is allowed alongside type=BOOLEAN`, 243 + ); 244 + } 245 + if ( 246 + param.defaultValue !== undefined && 247 + typeof param.defaultValue !== 'boolean' 248 + ) { 249 + throw makeInvalidParameterError( 250 + `${at(index, 'defaultValue')} must be a boolean for BOOLEAN parameters`, 251 + ); 252 + } 253 + } 254 + 255 + function validateSelectRules(param: ActionParameter, index: number): void { 256 + if (param.options === undefined || param.options.length === 0) { 257 + throw makeInvalidParameterError( 258 + `${at(index, 'options')} is required for ${param.type} parameters`, 259 + ); 260 + } 261 + if ( 262 + param.min !== undefined || 263 + param.max !== undefined || 264 + param.maxLength !== undefined 265 + ) { 266 + throw makeInvalidParameterError( 267 + `${at(index, '')} min/max/maxLength are not allowed for ${param.type} parameters`, 268 + ); 269 + } 270 + const optionValues = new Set<string>(); 271 + for (const [i, option] of param.options.entries()) { 272 + if (optionValues.has(option.value)) { 273 + throw makeInvalidParameterError( 274 + `${at(index, `options[${i}].value`)} "${option.value}" is duplicated`, 275 + ); 276 + } 277 + optionValues.add(option.value); 278 + } 279 + if (param.defaultValue === undefined) return; 280 + const ok = 281 + param.type === 'SELECT' 282 + ? typeof param.defaultValue === 'string' && 283 + optionValues.has(param.defaultValue) 284 + : Array.isArray(param.defaultValue) && 285 + param.defaultValue.every( 286 + (v) => typeof v === 'string' && optionValues.has(v), 287 + ); 288 + if (!ok) { 289 + throw makeInvalidParameterError( 290 + param.type === 'SELECT' 291 + ? `${at(index, 'defaultValue')} must be one of the option values` 292 + : `${at(index, 'defaultValue')} must be an array of option values`, 293 + ); 294 + } 295 + // An empty MULTISELECT default on a required field would silently pass the 296 + // runtime required-check. SELECT defaults of `''` are already rejected by 297 + // the `optionValues.has` check above (option values must be minLength 1). 298 + if ( 299 + param.required && 300 + param.type === 'MULTISELECT' && 301 + Array.isArray(param.defaultValue) && 302 + param.defaultValue.length === 0 303 + ) { 304 + throw makeInvalidParameterError( 305 + `${at(index, 'defaultValue')} cannot be empty when the parameter is required`, 306 + ); 307 + } 308 + } 309 + 310 + function formatAjvErrors(errors: readonly ErrorObject[] | null | undefined): string { 311 + if (!errors || errors.length === 0) return 'invalid action parameters'; 312 + return errors 313 + .map((err) => `${err.instancePath || '/'}: ${err.message ?? 'invalid value'}`) 314 + .join('; '); 315 + } 316 + 317 + /** 318 + * Recover a typed parameter list from the loose `JsonValue | null` stored in 319 + * `actions.custom_mrt_api_params`. Designed to be defensive: silently drops 320 + * any entry that doesn't validate so legacy rows written before the AJV- 321 + * validated authoring path (PR 1) don't crash readers/executors. 322 + * 323 + * Use this anywhere you need to act on an action's parameter spec at 324 + * execution time — distinct from `validateActionParameters`, which is the 325 + * write-side AJV validator. 326 + */ 327 + export function parseStoredParameters(value: unknown): ActionParameter[] { 328 + if (!Array.isArray(value)) return []; 329 + const allowedTypes = ACTION_PARAMETER_TYPES as readonly string[]; 330 + const out: ActionParameter[] = []; 331 + for (const raw of value) { 332 + if (typeof raw !== 'object' || raw === null) continue; 333 + const obj = raw as Record<string, unknown>; 334 + const name = typeof obj.name === 'string' ? obj.name : null; 335 + const displayName = typeof obj.displayName === 'string' ? obj.displayName : null; 336 + const typeRaw = typeof obj.type === 'string' ? obj.type : null; 337 + if (name === null || displayName === null || typeRaw === null) continue; 338 + if (!allowedTypes.includes(typeRaw)) continue; 339 + const parsed: ActionParameter = { 340 + name, 341 + displayName, 342 + type: typeRaw as ActionParameterType, 343 + required: obj.required === true, 344 + }; 345 + if (typeof obj.description === 'string') parsed.description = obj.description; 346 + if (Array.isArray(obj.options)) { 347 + const options: ActionParameterOption[] = []; 348 + for (const opt of obj.options) { 349 + if (typeof opt !== 'object' || opt === null) continue; 350 + const o = opt as Record<string, unknown>; 351 + if (typeof o.value === 'string' && typeof o.label === 'string') { 352 + options.push({ value: o.value, label: o.label }); 353 + } 354 + } 355 + if (options.length > 0) parsed.options = options; 356 + } 357 + if (typeof obj.min === 'number') parsed.min = obj.min; 358 + if (typeof obj.max === 'number') parsed.max = obj.max; 359 + if (typeof obj.maxLength === 'number') parsed.maxLength = obj.maxLength; 360 + if ('defaultValue' in obj) parsed.defaultValue = obj.defaultValue; 361 + out.push(parsed); 362 + } 363 + return out; 364 + } 365 + 366 + function makeInvalidParameterError(detail: string) { 367 + return makeBadRequestError('Invalid action parameters', { 368 + detail, 369 + shouldErrorSpan: false, 370 + }); 371 + } 372 + 373 + /** 374 + * Serialize a validated parameter list to the shape the DB driver expects for 375 + * `actions.custom_mrt_api_params jsonb[]`. Returns `[]` (the column default) 376 + * when the caller passed an empty list, so writes stay deterministic. 377 + */ 378 + export function serializeParameters( 379 + parameters: readonly ActionParameter[], 380 + ): JsonValue[] { 381 + return parameters.map((param) => { 382 + const out: Record<string, JsonValue> = { 383 + name: param.name, 384 + displayName: param.displayName, 385 + type: param.type, 386 + required: param.required, 387 + }; 388 + if (param.description !== undefined) out.description = param.description; 389 + if (param.options !== undefined) { 390 + out.options = param.options.map((o) => ({ value: o.value, label: o.label })); 391 + } 392 + if (param.min !== undefined) out.min = param.min; 393 + if (param.max !== undefined) out.max = param.max; 394 + if (param.maxLength !== undefined) out.maxLength = param.maxLength; 395 + if (param.defaultValue !== undefined) out.defaultValue = param.defaultValue as JsonValue; 396 + return out; 397 + }); 398 + }
+15
server/services/moderationConfigService/modules/actorNoteValidation.ts
··· 1 + import { makeBadRequestError } from '../../../utils/errors.js'; 2 + 3 + // Enforced at every entry point (REST + GraphQL) so an oversized note can't 4 + // bypass the AJV boundary by going through the GraphQL resolver. 5 + export const MAX_ACTOR_NOTE_LENGTH = 5000; 6 + 7 + export function validateActorNote(note: string | null | undefined): void { 8 + if (note == null) return; 9 + if (note.length > MAX_ACTOR_NOTE_LENGTH) { 10 + throw makeBadRequestError( 11 + `Moderator note exceeds maximum length of ${MAX_ACTOR_NOTE_LENGTH} characters (got ${note.length})`, 12 + { shouldErrorSpan: false }, 13 + ); 14 + } 15 + }
+2
server/storage/dataWarehouse/IDataWarehouseAnalytics.ts
··· 58 58 policies: readonly unknown[]; 59 59 actor_id?: string; 60 60 job_id?: string; 61 + parameters: string; 62 + actor_note?: string; 61 63 failed: boolean; 62 64 }; 63 65
+12
server/storage/dataWarehouse/warehouseSchema.ts
··· 294 294 POLICIES: ReadonlyDeep<ActionExecutionPolicy[]>; 295 295 FAILED: boolean; 296 296 JOB_ID: string | null; 297 + /** 298 + * JSON-encoded `name -> value` map of moderator-supplied parameter values 299 + * for this execution. Empty object when no parameters were supplied (the 300 + * common case for actions without a parameter spec). 301 + */ 302 + PARAMETERS: JsonOf<Record<string, unknown>>; 303 + /** 304 + * Optional free-text note from the moderator. Surfaces in the audit trail 305 + * alongside `actor_id` so reviewers can see why an action ran without 306 + * cross-referencing other systems. 307 + */ 308 + ACTOR_NOTE: string | null; 297 309 TS: ColumnType<WarehouseDate, number, never>; 298 310 DS: ColumnType<FilterableWarehouseDate, string, never>; 299 311 };