feat(workflow): condition filter on database-event triggers (#21868)

## Problem

Connecting a mailbox bulk-creates contacts via the email/calendar sync,
and each `person.upserted` fires the seeded **"Create company when
adding a new person"** workflow. The trigger enqueues one run per record
(no batching) and each run bills several `WORKFLOW_NODE_RUN` events — so
a single mailbox connect can rack up tens of thousands of runs and
exhaust credits on a brand-new workspace. The workflow is also redundant
on that path: the sync already creates the company from the email domain
and links the person to it.

## What this does

Adds an optional, user-defined **filter** to database-event (listener)
triggers, evaluated in the listener **before a run is enqueued**.
Non-matching events never create a run, so they consume zero execution
credits. This is the Filter node's capability, lifted to the trigger
level, and available for all event types (created / updated / upserted /
deleted).

The seeded "Create company when adding a new person" workflow now
carries a visible trigger filter — `Created by → Source is not Email`
**and** `is not Calendar` — so it no longer runs for sync-created
contacts, while still running for manually / API / CSV-added people.

## How (reuse)

- **Backend:** extracted `evaluateStepFilters()`, shared by the Filter
action and the trigger listener's new `eventMatchesRecordFilter` gate.
The record is exposed under the `trigger` key so filters reference it
exactly like steps do (`{{trigger.properties.after.…}}`).
- **Shared:** one optional `filter` added to the database-event trigger
zod schema; the front-end type derives from it (settings stay JSON — no
codegen).
- **Frontend:** extracted `WorkflowStepFilterBuilder` from the Filter
action's body; both the Filter action and the trigger editor render it.
The field picker needed no changes — at the trigger it already resolves
to the record's own fields via `TRIGGER_STEP_ID`.

## Scope / decisions

- **No migration for existing workspaces** (by request) — only newly
created workspaces get the filtered default; already-created workspaces
keep the always-on workflow.
- Deliberately did **not** add relation-enrichment to the upsert path
(it would add a DB lookup to the very bulk-sync path we're relieving).
Trigger filters work on the record's own scalar/composite fields (e.g.
`createdBy.source`); relation-based filters work on created/updated
where enrichment already runs.

## Verification

- Typecheck: `twenty-shared`, `twenty-server`, `twenty-front` all green.
- Lint (diff, autofix): 0 warnings / 0 errors across all three.
- Unit tests: a new `evaluate-step-filters` spec exercising the exact
`createdBy.source IS_NOT` seed mechanism, plus new listener specs
proving non-matching events are not enqueued. All backend
filter/listener suites pass.
- Not run here: integration tests (need a DB) and Storybook.

https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De

---
_Generated by [Claude
Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21868?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-06-21 17:47:06 +02:00
committed by GitHub
parent f8db73598c
commit a0689d1577
19 changed files with 844 additions and 264 deletions
@@ -5,22 +5,14 @@ import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/filters/states/currentStepFiltersComponentState';
import { hasInitializedCurrentStepFilterGroupsComponentFamilyState } from '@/workflow/workflow-steps/filters/states/hasInitializedCurrentStepFilterGroupsComponentFamilyState';
import { hasInitializedCurrentStepFiltersComponentFamilyState } from '@/workflow/workflow-steps/filters/states/hasInitializedCurrentStepFiltersComponentFamilyState';
import { type FilterSettingsWithPotentiallyDeprecatedOperand } from '@/workflow/workflow-steps/filters/types/FilterSettings';
import { useEffect, useMemo } from 'react';
import {
type StepFilterGroup,
type StepFilterWithPotentiallyDeprecatedOperand,
} from 'twenty-shared/types';
import {
convertViewFilterOperandToCoreOperand,
isDefined,
} from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type FilterSettingsWithPotentiallyDeprecatedOperand = {
stepFilterGroups?: StepFilterGroup[];
stepFilters?: StepFilterWithPotentiallyDeprecatedOperand[];
};
export const WorkflowEditActionFilterBodyEffect = ({
stepId,
defaultValue,
@@ -0,0 +1,127 @@
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/filters/components/WorkflowEditActionFilterBodyEffect';
import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddFilterRuleSelect';
import { WorkflowStepFilterAddRootStepFilterButton } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddRootStepFilterButton';
import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterColumn';
import { WorkflowStepFilterGroupColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterGroupColumn';
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/filters/hooks/useChildStepFiltersAndChildStepFilterGroups';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFilterGroupsComponentInstanceContext';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFiltersComponentInstanceContext';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/filters/states/context/WorkflowStepFilterContext';
import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/filters/states/rootLevelStepFilterGroupComponentSelector';
import {
type FilterSettings,
type FilterSettingsWithPotentiallyDeprecatedOperand,
} from '@/workflow/workflow-steps/filters/types/FilterSettings';
import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/filters/utils/isStepFilterGroupChildAStepFilterGroup';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: start;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledChildContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
width: 100%;
`;
const StyledConditions = styled.div`
display: flex;
flex-direction: column;
row-gap: ${themeCssVariables.spacing[0]};
`;
type WorkflowStepFilterBuilderProps = {
instanceId: string;
defaultValue?: FilterSettingsWithPotentiallyDeprecatedOperand;
readonly?: boolean;
onFilterSettingsUpdate: (filterSettings: FilterSettings) => void;
};
const WorkflowStepFilterBuilderConditions = ({
readonly,
}: {
readonly?: boolean;
}) => {
const rootStepFilterGroup = useAtomComponentSelectorValue(
rootLevelStepFilterGroupComponentSelector,
);
const { childStepFiltersAndChildStepFilterGroups } =
useChildStepFiltersAndChildStepFilterGroups({
stepFilterGroupId: rootStepFilterGroup?.id ?? '',
});
return (
<StyledConditions>
<InputLabel>{t`Conditions`}</InputLabel>
{isDefined(rootStepFilterGroup) ? (
<StyledContainer>
<StyledChildContainer>
{childStepFiltersAndChildStepFilterGroups.map(
(stepFilterGroupChild, stepFilterGroupChildIndex) =>
isStepFilterGroupChildAStepFilterGroup(stepFilterGroupChild) ? (
<WorkflowStepFilterGroupColumn
key={stepFilterGroupChild.id}
parentStepFilterGroup={rootStepFilterGroup}
stepFilterGroup={stepFilterGroupChild}
stepFilterGroupIndex={stepFilterGroupChildIndex}
/>
) : (
<WorkflowStepFilterColumn
key={stepFilterGroupChild.id}
stepFilterGroup={rootStepFilterGroup}
stepFilter={stepFilterGroupChild}
stepFilterIndex={stepFilterGroupChildIndex}
/>
),
)}
</StyledChildContainer>
{!readonly && (
<WorkflowStepFilterAddFilterRuleSelect
stepFilterGroup={rootStepFilterGroup}
/>
)}
</StyledContainer>
) : (
<WorkflowStepFilterAddRootStepFilterButton />
)}
</StyledConditions>
);
};
export const WorkflowStepFilterBuilder = ({
instanceId,
defaultValue,
readonly,
onFilterSettingsUpdate,
}: WorkflowStepFilterBuilderProps) => {
return (
<StepFiltersComponentInstanceContext.Provider value={{ instanceId }}>
<StepFilterGroupsComponentInstanceContext.Provider value={{ instanceId }}>
<WorkflowStepFilterContext.Provider
value={{
stepId: instanceId,
readonly,
onFilterSettingsUpdate,
}}
>
<WorkflowStepFilterBuilderConditions readonly={readonly} />
</WorkflowStepFilterContext.Provider>
<WorkflowEditActionFilterBodyEffect
stepId={instanceId}
defaultValue={defaultValue}
/>
</StepFilterGroupsComponentInstanceContext.Provider>
</StepFiltersComponentInstanceContext.Provider>
);
};
@@ -14,7 +14,10 @@ import { useLingui } from '@lingui/react/macro';
import { useContext, useState } from 'react';
import { type StepFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { extractRawVariableNamePart } from 'twenty-shared/workflow';
import {
extractRawVariableNamePart,
TRIGGER_STEP_ID,
} from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/icon';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -30,7 +33,9 @@ const NON_SELECTABLE_FIELD_TYPES = [
export const WorkflowStepFilterFieldSelect = ({
stepFilter,
}: WorkflowStepFilterFieldSelectProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { readonly, stepId: currentStepId } = useContext(
WorkflowStepFilterContext,
);
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
@@ -88,7 +93,9 @@ export const WorkflowStepFilterFieldSelect = ({
const isSelectedFieldNotFound = !isDefined(variableLabel);
const label = isSelectedFieldNotFound
? t`Select a field from a previous step`
? currentStepId === TRIGGER_STEP_ID
? t`Select a field`
: t`Select a field from a previous step`
: variableLabel;
const fullRecordIconProps = stepFilter.isFullRecord
@@ -0,0 +1,50 @@
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFilterBuilder } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterBuilder';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const meta: Meta<typeof WorkflowStepFilterBuilder> = {
title: 'Modules/Workflow/Filters/WorkflowStepFilterBuilder',
component: WorkflowStepFilterBuilder,
parameters: {
msw: graphqlMocks,
},
args: {
instanceId: getWorkflowNodeIdMock(),
defaultValue: {
stepFilterGroups: [],
stepFilters: [],
},
readonly: false,
onFilterSettingsUpdate: fn(),
},
decorators: [
(Story) => (
<WorkflowStepBody rowGap={themeCssVariables.spacing[0]}>
<Story />
</WorkflowStepBody>
),
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterBuilder>;
export const Default: Story = {};
export const ReadOnly: Story = {
args: {
readonly: true,
},
};
@@ -1,6 +1,15 @@
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
import {
type StepFilter,
type StepFilterGroup,
type StepFilterWithPotentiallyDeprecatedOperand,
} from 'twenty-shared/types';
export type FilterSettings = {
stepFilterGroups?: StepFilterGroup[];
stepFilters?: StepFilter[];
};
export type FilterSettingsWithPotentiallyDeprecatedOperand = {
stepFilterGroups?: StepFilterGroup[];
stepFilters?: StepFilterWithPotentiallyDeprecatedOperand[];
};
@@ -1,9 +1,9 @@
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/filters/components/WorkflowEditActionFilterBodyEffect';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFilterGroupsComponentInstanceContext';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFiltersComponentInstanceContext';
import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody';
import { WorkflowStepFilterBuilder } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterBuilder';
import { type FilterSettings } from '@/workflow/workflow-steps/filters/types/FilterSettings';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type WorkflowEditActionFilterProps = {
action: WorkflowFilterAction;
@@ -21,31 +21,33 @@ export const WorkflowEditActionFilter = ({
action,
actionOptions,
}: WorkflowEditActionFilterProps) => {
const handleFilterSettingsUpdate = (filterSettings: FilterSettings) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
stepFilterGroups: filterSettings.stepFilterGroups ?? [],
stepFilters: filterSettings.stepFilters ?? [],
},
},
});
};
return (
<>
<StepFiltersComponentInstanceContext.Provider
value={{
instanceId: action.id,
}}
>
<StepFilterGroupsComponentInstanceContext.Provider
value={{
instanceId: action.id,
}}
>
<WorkflowEditActionFilterBody
action={action}
actionOptions={actionOptions}
/>
<WorkflowEditActionFilterBodyEffect
stepId={action.id}
defaultValue={{
stepFilterGroups: action.settings.input.stepFilterGroups,
stepFilters: action.settings.input.stepFilters,
}}
/>
</StepFilterGroupsComponentInstanceContext.Provider>
</StepFiltersComponentInstanceContext.Provider>
<WorkflowStepBody rowGap={themeCssVariables.spacing[0]}>
<WorkflowStepFilterBuilder
instanceId={action.id}
defaultValue={action.settings.input}
readonly={actionOptions.readonly}
onFilterSettingsUpdate={handleFilterSettingsUpdate}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
);
@@ -1,121 +0,0 @@
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddFilterRuleSelect';
import { WorkflowStepFilterAddRootStepFilterButton } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddRootStepFilterButton';
import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterColumn';
import { WorkflowStepFilterGroupColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterGroupColumn';
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/filters/hooks/useChildStepFiltersAndChildStepFilterGroups';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/filters/states/context/WorkflowStepFilterContext';
import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/filters/states/rootLevelStepFilterGroupComponentSelector';
import { type FilterSettings } from '@/workflow/workflow-steps/filters/types/FilterSettings';
import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/filters/utils/isStepFilterGroupChildAStepFilterGroup';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: start;
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledChildContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
width: 100%;
`;
type WorkflowEditActionFilterBodyProps = {
action: WorkflowFilterAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowFilterAction) => void;
};
};
export const WorkflowEditActionFilterBody = ({
action,
actionOptions,
}: WorkflowEditActionFilterBodyProps) => {
const rootStepFilterGroup = useAtomComponentSelectorValue(
rootLevelStepFilterGroupComponentSelector,
);
const { childStepFiltersAndChildStepFilterGroups } =
useChildStepFiltersAndChildStepFilterGroups({
stepFilterGroupId: rootStepFilterGroup?.id ?? '',
});
const onFilterSettingsUpdate = (newFilterSettings: FilterSettings) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
stepFilterGroups: newFilterSettings.stepFilterGroups ?? [],
stepFilters: newFilterSettings.stepFilters ?? [],
},
},
});
};
return (
<WorkflowStepFilterContext.Provider
value={{
stepId: action.id,
readonly: actionOptions.readonly,
onFilterSettingsUpdate,
}}
>
<WorkflowStepBody rowGap={themeCssVariables.spacing[0]}>
<InputLabel>{t`Conditions`}</InputLabel>
{isDefined(rootStepFilterGroup) ? (
<StyledContainer>
<StyledChildContainer>
{childStepFiltersAndChildStepFilterGroups.map(
(stepFilterGroupChild, stepFilterGroupChildIndex) =>
isStepFilterGroupChildAStepFilterGroup(
stepFilterGroupChild,
) ? (
<WorkflowStepFilterGroupColumn
key={stepFilterGroupChild.id}
parentStepFilterGroup={rootStepFilterGroup}
stepFilterGroup={stepFilterGroupChild}
stepFilterGroupIndex={stepFilterGroupChildIndex}
/>
) : (
<WorkflowStepFilterColumn
key={stepFilterGroupChild.id}
stepFilterGroup={rootStepFilterGroup}
stepFilter={stepFilterGroupChild}
stepFilterIndex={stepFilterGroupChildIndex}
/>
),
)}
</StyledChildContainer>
{!actionOptions.readonly && (
<WorkflowStepFilterAddFilterRuleSelect
stepFilterGroup={rootStepFilterGroup}
/>
)}
</StyledContainer>
) : (
<WorkflowStepFilterAddRootStepFilterButton />
)}
</WorkflowStepBody>
</WorkflowStepFilterContext.Provider>
);
};
@@ -1,69 +0,0 @@
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const DEFAULT_ACTION: WorkflowFilterAction = {
id: getWorkflowNodeIdMock(),
name: 'Filter Records',
type: 'FILTER',
valid: false,
settings: {
input: {
stepFilterGroups: [],
stepFilters: [],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionFilterBody> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowEditActionFilterBody',
component: WorkflowEditActionFilterBody,
parameters: {
msw: graphqlMocks,
},
args: {
action: DEFAULT_ACTION,
actionOptions: {
readonly: false,
onActionUpdate: fn(),
},
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFilterBody>;
export const Default: Story = {};
export const ReadOnly: Story = {
args: {
action: DEFAULT_ACTION,
actionOptions: {
readonly: true,
},
},
};
@@ -16,6 +16,8 @@ import { type WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowStepFilterBuilder } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterBuilder';
import { type FilterSettings } from '@/workflow/workflow-steps/filters/types/FilterSettings';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useCallback, useMemo, useState } from 'react';
@@ -155,6 +157,23 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
});
};
const handleFilterSettingsUpdate = (filterSettings: FilterSettings) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
filter: {
stepFilterGroups: filterSettings.stepFilterGroups ?? [],
stepFilters: filterSettings.stepFilters ?? [],
},
},
});
};
const handleSystemObjectsClick = () => {
setIsSystemObjectsOpen(true);
setSearchInputValue('');
@@ -269,6 +288,19 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
actionType="DATABASE_EVENT"
/>
)}
{isDefined(selectedObjectMetadataItem) && (
<WorkflowStepFilterBuilder
instanceId={TRIGGER_STEP_ID}
defaultValue={
trigger.settings.filter ?? {
stepFilterGroups: [],
stepFilters: [],
}
}
readonly={triggerOptions.readonly ?? false}
onFilterSettingsUpdate={handleFilterSettingsUpdate}
/>
)}
</WorkflowStepBody>
{!triggerOptions.readonly && (
<WorkflowStepFooter stepId={TRIGGER_STEP_ID} />
@@ -0,0 +1,58 @@
import { buildPersonSyncSourceFilter } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/build-person-sync-source-filter.util';
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
describe('buildPersonSyncSourceFilter', () => {
const filter = buildPersonSyncSourceFilter({
createdByFieldMetadataId: 'created-by-field-id',
});
const evaluateForSource = (source?: string) =>
evaluateStepFilters({
stepFilters: filter.stepFilters,
stepFilterGroups: filter.stepFilterGroups,
context: {
trigger: {
properties: {
after: {
createdBy: source === undefined ? {} : { source },
},
},
},
},
});
it('suppresses people auto-created by the email sync', () => {
expect(evaluateForSource('EMAIL')).toBe(false);
});
it('suppresses people auto-created by the calendar sync', () => {
expect(evaluateForSource('CALENDAR')).toBe(false);
});
it.each(['MANUAL', 'API', 'IMPORT', 'WORKFLOW', 'SYSTEM', 'WEBHOOK'])(
'runs the workflow for people created via %s',
(source) => {
expect(evaluateForSource(source)).toBe(true);
},
);
it('runs the workflow when the createdBy source is missing (fails open)', () => {
expect(evaluateForSource(undefined)).toBe(true);
});
it('builds two ANDed source filters that reference the given field', () => {
expect(filter.stepFilterGroups).toHaveLength(1);
expect(filter.stepFilterGroups[0].logicalOperator).toBe('AND');
expect(filter.stepFilters).toHaveLength(2);
expect(
filter.stepFilters.every(
(stepFilter) =>
stepFilter.fieldMetadataId === 'created-by-field-id' &&
stepFilter.operand === 'IS_NOT' &&
stepFilter.compositeFieldSubFieldName === 'source' &&
stepFilter.stepFilterGroupId === filter.stepFilterGroups[0].id,
),
).toBe(true);
});
});
@@ -0,0 +1,54 @@
import {
FieldActorSource,
type StepFilter,
type StepFilterGroup,
StepLogicalOperator,
ViewFilterOperand,
} from 'twenty-shared/types';
const PERSON_SYNC_SOURCE_FILTER_GROUP_ID =
'2d9c1f3a-6b4e-4c8a-9f12-7a3b5c6d8e90';
const PERSON_SYNC_SOURCE_EMAIL_FILTER_ID =
'3e8b2a4c-7c5f-4d9b-8a23-6b4c5d7e9f01';
const PERSON_SYNC_SOURCE_CALENDAR_FILTER_ID =
'4f9c3b5d-8d6a-4e0c-9b34-7c5d6e8f0a12';
export const buildPersonSyncSourceFilter = ({
createdByFieldMetadataId,
}: {
createdByFieldMetadataId: string;
}): { stepFilterGroups: StepFilterGroup[]; stepFilters: StepFilter[] } => {
const baseFilter = {
type: 'ACTOR',
operand: ViewFilterOperand.IS_NOT,
stepOutputKey: '{{trigger.properties.after.createdBy.source}}',
stepFilterGroupId: PERSON_SYNC_SOURCE_FILTER_GROUP_ID,
compositeFieldSubFieldName: 'source',
fieldMetadataId: createdByFieldMetadataId,
};
return {
stepFilterGroups: [
{
id: PERSON_SYNC_SOURCE_FILTER_GROUP_ID,
logicalOperator: StepLogicalOperator.AND,
},
],
stepFilters: [
{
...baseFilter,
id: PERSON_SYNC_SOURCE_EMAIL_FILTER_ID,
value: JSON.stringify([FieldActorSource.EMAIL]),
positionInStepFilterGroup: 0,
},
{
...baseFilter,
id: PERSON_SYNC_SOURCE_CALENDAR_FILTER_ID,
value: JSON.stringify([FieldActorSource.CALENDAR]),
positionInStepFilterGroup: 1,
},
],
};
};
@@ -8,6 +8,7 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { buildPersonSyncSourceFilter } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/build-person-sync-source-filter.util';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
@@ -75,6 +76,22 @@ export const prefillWorkflows = async (
throw new Error('Company domainName field metadata not found');
}
const personCreatedByFieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(fieldMetadata) =>
fieldMetadata?.objectMetadataId === personObjectMetadataId &&
fieldMetadata?.name === 'createdBy',
);
if (!isDefined(personCreatedByFieldMetadata)) {
throw new Error('Person createdBy field metadata not found');
}
const personSyncSourceFilter = buildPersonSyncSourceFilter({
createdByFieldMetadataId: personCreatedByFieldMetadata.id,
});
await entityManager
.createQueryBuilder()
.insert()
@@ -356,6 +373,7 @@ export const prefillWorkflows = async (
},
DatabaseEventAction.UPSERTED,
),
filter: personSyncSourceFilter,
},
nextStepIds: ['c30d7cbe-00e0-4966-bc1a-99b0a11a2cca'],
}),
@@ -755,6 +773,7 @@ export const prefillWorkflows = async (
settings: {
eventName: 'person.upserted',
fields: ['emails'],
filter: personSyncSourceFilter,
},
},
])
@@ -1,7 +1,5 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import {
@@ -12,7 +10,7 @@ import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { isWorkflowFilterAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
@Injectable()
export class FilterWorkflowAction implements WorkflowAction {
@@ -41,15 +39,10 @@ export class FilterWorkflowAction implements WorkflowAction {
};
}
const resolvedFilters = stepFilters.map((filter) => ({
...filter,
rightOperand: resolveInput(filter.value, context),
leftOperand: resolveInput(filter.stepOutputKey, context),
}));
const matchesFilter = evaluateFilterConditions({
filterGroups: stepFilterGroups,
filters: resolvedFilters,
const matchesFilter = evaluateStepFilters({
stepFilters,
stepFilterGroups,
context,
});
return {
@@ -0,0 +1,143 @@
import {
type StepFilter,
type StepFilterGroup,
StepLogicalOperator,
ViewFilterOperand,
} from 'twenty-shared/types';
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
describe('evaluateStepFilters', () => {
const context = {
trigger: {
properties: {
after: {
createdBy: { source: 'EMAIL' },
name: 'Acme',
},
},
},
};
const group: StepFilterGroup = {
id: 'group-1',
logicalOperator: StepLogicalOperator.AND,
};
const sourceFilter = (operand: ViewFilterOperand): StepFilter => ({
id: 'filter-1',
type: 'ACTOR',
operand,
value: JSON.stringify(['EMAIL']),
stepOutputKey: '{{trigger.properties.after.createdBy.source}}',
stepFilterGroupId: group.id,
compositeFieldSubFieldName: 'source',
});
it('returns true when there are no filters', () => {
expect(
evaluateStepFilters({
stepFilters: [],
stepFilterGroups: [],
context,
}),
).toBe(true);
});
it('resolves operands from the context and matches the record', () => {
expect(
evaluateStepFilters({
stepFilterGroups: [group],
stepFilters: [sourceFilter(ViewFilterOperand.IS)],
context,
}),
).toBe(true);
});
it('returns false when the record source is excluded (IS_NOT)', () => {
expect(
evaluateStepFilters({
stepFilterGroups: [group],
stepFilters: [sourceFilter(ViewFilterOperand.IS_NOT)],
context,
}),
).toBe(false);
});
it('returns true when a different source is excluded (IS_NOT)', () => {
const calendarFilter: StepFilter = {
...sourceFilter(ViewFilterOperand.IS_NOT),
value: JSON.stringify(['CALENDAR']),
};
expect(
evaluateStepFilters({
stepFilterGroups: [group],
stepFilters: [calendarFilter],
context,
}),
).toBe(true);
});
it('evaluates IS_NOT_EMPTY against a present field when no value is set', () => {
const filter: StepFilter = {
id: 'filter-present',
type: 'TEXT',
operand: ViewFilterOperand.IS_NOT_EMPTY,
value: '',
stepOutputKey: '{{trigger.properties.after.name}}',
stepFilterGroupId: group.id,
};
expect(
evaluateStepFilters({
stepFilterGroups: [group],
stepFilters: [filter],
context,
}),
).toBe(true);
});
it('resolves a missing field path to empty (IS_EMPTY is true)', () => {
const filter: StepFilter = {
id: 'filter-missing',
type: 'TEXT',
operand: ViewFilterOperand.IS_EMPTY,
value: '',
stepOutputKey: '{{trigger.properties.after.missingField}}',
stepFilterGroupId: group.id,
};
expect(
evaluateStepFilters({
stepFilterGroups: [group],
stepFilters: [filter],
context,
}),
).toBe(true);
});
it('applies implicit AND across flat filters without groups', () => {
const nameContains: StepFilter = {
id: 'name-contains',
type: 'TEXT',
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
stepOutputKey: '{{trigger.properties.after.name}}',
stepFilterGroupId: 'unused',
};
const sourceIsCalendar: StepFilter = {
...sourceFilter(ViewFilterOperand.IS),
value: JSON.stringify(['CALENDAR']),
stepFilterGroupId: 'unused',
};
expect(
evaluateStepFilters({
stepFilterGroups: [],
stepFilters: [nameContains, sourceIsCalendar],
context,
}),
).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
import { resolveInput } from 'twenty-shared/utils';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
export const evaluateStepFilters = ({
stepFilters,
stepFilterGroups,
context,
}: {
stepFilters: StepFilter[];
stepFilterGroups: StepFilterGroup[];
context: Record<string, unknown>;
}): boolean => {
const resolvedFilters = stepFilters.map((filter) => ({
...filter,
rightOperand: resolveInput(filter.value, context),
leftOperand: resolveInput(filter.stepOutputKey, context),
}));
return evaluateFilterConditions({
filterGroups: stepFilterGroups,
filters: resolvedFilters,
});
};
@@ -1,5 +1,13 @@
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
export type DatabaseEventTriggerFilterSettings = {
stepFilters: StepFilter[];
stepFilterGroups: StepFilterGroup[];
};
export type BaseDatabaseEventTriggerSettings = {
eventName: string;
filter?: DatabaseEventTriggerFilterSettings;
};
export type DatabaseEventTriggerSettings =
@@ -9,7 +9,8 @@ import {
type ObjectRecordUpsertEvent,
} from 'twenty-shared/database-events';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { In, Raw } from 'typeorm';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
@@ -31,15 +32,22 @@ import {
type WorkflowAutomatedTriggerWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
import {
type BaseDatabaseEventTriggerSettings,
type UpdateEventTriggerSettings,
type UpsertEventTriggerSettings,
} from 'src/modules/workflow/workflow-trigger/automated-trigger/constants/automated-trigger-settings';
import {
WorkflowTriggerJob,
type WorkflowTriggerJobData,
} from 'src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job';
type TriggerEvaluationArgs = {
eventPayload: ObjectRecordEvent;
eventListener: WorkflowAutomatedTriggerWorkspaceEntity;
action: DatabaseEventAction;
};
@Injectable()
export class WorkflowDatabaseEventTriggerListener {
private readonly logger = new Logger(
@@ -386,27 +394,26 @@ export class WorkflowDatabaseEventTriggerListener {
eventPayload,
eventListener,
action,
}: {
eventPayload: ObjectRecordEvent;
eventListener: WorkflowAutomatedTriggerWorkspaceEntity;
action: DatabaseEventAction;
}) {
if (action === DatabaseEventAction.UPDATED) {
}: TriggerEvaluationArgs) {
return (
this.eventMatchesWatchedFields({ eventPayload, eventListener, action }) &&
this.eventMatchesRecordFilter({ eventPayload, eventListener })
);
}
private eventMatchesWatchedFields({
eventPayload,
eventListener,
action,
}: TriggerEvaluationArgs) {
if (
action === DatabaseEventAction.UPDATED ||
action === DatabaseEventAction.UPSERTED
) {
const settings = eventListener.settings as UpdateEventTriggerSettings;
const updateEventPayload = eventPayload as ObjectRecordUpdateEvent;
const updatedFields = updateEventPayload?.properties?.updatedFields ?? [];
return (
!settings.fields ||
settings.fields.length === 0 ||
settings.fields.some((field) => updatedFields.includes(field))
);
}
if (action === DatabaseEventAction.UPSERTED) {
const settings = eventListener.settings as UpsertEventTriggerSettings;
const upsertEventPayload = eventPayload as ObjectRecordUpsertEvent;
const updatedFields = upsertEventPayload?.properties?.updatedFields ?? [];
const updatedFields =
(eventPayload as ObjectRecordUpdateEvent)?.properties?.updatedFields ??
[];
return (
!settings.fields ||
@@ -417,4 +424,32 @@ export class WorkflowDatabaseEventTriggerListener {
return true;
}
private eventMatchesRecordFilter({
eventPayload,
eventListener,
}: Pick<TriggerEvaluationArgs, 'eventPayload' | 'eventListener'>) {
const { filter } =
eventListener.settings as BaseDatabaseEventTriggerSettings;
if (!isDefined(filter) || !isNonEmptyArray(filter.stepFilters)) {
return true;
}
try {
return evaluateStepFilters({
stepFilters: filter.stepFilters,
stepFilterGroups: filter.stepFilterGroups,
context: { [TRIGGER_STEP_ID]: eventPayload },
});
} catch (error) {
this.logger.error(
`Failed to evaluate database-event trigger filter for workflow ${eventListener.workflowId}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
}
}
@@ -0,0 +1,205 @@
import request from 'supertest';
const client = request(`http://localhost:${APP_PORT}`);
const STEP_FILTER_GROUP_ID = 'a1b2c3d4-1111-4a2b-8c3d-000000000001';
const STEP_FILTER_ID = 'a1b2c3d4-2222-4a2b-8c3d-000000000002';
const FILTER_VALUE = 'trigger-me-co';
type AutomatedTriggerNode = {
type: string;
workflowId: string;
settings: {
eventName?: string;
filter?: {
stepFilters: Array<Record<string, unknown>>;
stepFilterGroups: Array<Record<string, unknown>>;
};
};
};
const graphql = (query: string, variables?: object) =>
client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
describe('Database event trigger filter (e2e)', () => {
let createdWorkflowId: string | null = null;
let createdWorkflowVersionId: string | null = null;
beforeAll(async () => {
const createWorkflowResponse = await graphql(`
mutation CreateWorkflow {
createWorkflow(data: { name: "DB Event Trigger Filter Test" }) {
id
}
}
`);
expect(createWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
const getWorkflowResponse = await graphql(
`
query GetWorkflow($id: UUID!) {
workflow(filter: { id: { eq: $id } }) {
id
versions {
edges {
node {
id
}
}
}
}
}
`,
{ id: createdWorkflowId },
);
expect(getWorkflowResponse.body.errors).toBeUndefined();
createdWorkflowVersionId =
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
const databaseEventTrigger = {
name: 'Company is created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
filter: {
stepFilterGroups: [
{ id: STEP_FILTER_GROUP_ID, logicalOperator: 'AND' },
],
stepFilters: [
{
id: STEP_FILTER_ID,
type: 'TEXT',
operand: 'CONTAINS',
value: FILTER_VALUE,
stepOutputKey: '{{trigger.properties.after.name}}',
stepFilterGroupId: STEP_FILTER_GROUP_ID,
},
],
},
},
nextStepIds: [],
position: { x: 0, y: 0 },
};
const updateTriggerResponse = await graphql(
`
mutation UpdateWorkflowVersion(
$id: UUID!
$data: WorkflowVersionUpdateInput!
) {
updateWorkflowVersion(id: $id, data: $data) {
id
}
}
`,
{
id: createdWorkflowVersionId,
data: { trigger: databaseEventTrigger },
},
);
expect(updateTriggerResponse.body.errors).toBeUndefined();
const createStepResponse = await graphql(
`
mutation CreateWorkflowVersionStep(
$input: CreateWorkflowVersionStepInput!
) {
createWorkflowVersionStep(input: $input) {
stepsDiff
}
}
`,
{
input: {
workflowVersionId: createdWorkflowVersionId,
stepType: 'CODE',
parentStepId: 'trigger',
position: { x: 200, y: 0 },
},
},
);
expect(createStepResponse.body.errors).toBeUndefined();
const activateResponse = await graphql(
`
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
}
`,
{ workflowVersionId: createdWorkflowVersionId },
);
expect(activateResponse.body.errors).toBeUndefined();
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
});
afterAll(async () => {
if (createdWorkflowId) {
await graphql(
`
mutation DestroyWorkflow($id: ID!) {
destroyWorkflow(id: $id) {
id
}
}
`,
{ id: createdWorkflowId },
);
}
});
it('syncs the trigger filter onto the workflowAutomatedTrigger row read by the listener', async () => {
const response = await graphql(
`
query WorkflowAutomatedTriggers($workflowId: UUID!) {
workflowAutomatedTriggers(
filter: { workflowId: { eq: $workflowId } }
) {
edges {
node {
type
settings
workflowId
}
}
}
}
`,
{ workflowId: createdWorkflowId },
);
expect(response.body.errors).toBeUndefined();
const automatedTriggers: AutomatedTriggerNode[] =
response.body.data.workflowAutomatedTriggers.edges.map(
(edge: { node: AutomatedTriggerNode }) => edge.node,
);
expect(automatedTriggers).toHaveLength(1);
const automatedTrigger = automatedTriggers[0];
expect(automatedTrigger.type).toBe('DATABASE_EVENT');
expect(automatedTrigger.settings.eventName).toBe('company.created');
const filter = automatedTrigger.settings.filter;
expect(filter).toBeDefined();
expect(filter?.stepFilterGroups).toHaveLength(1);
expect(filter?.stepFilters).toHaveLength(1);
expect(filter?.stepFilters[0]).toMatchObject({
operand: 'CONTAINS',
value: FILTER_VALUE,
stepOutputKey: '{{trigger.properties.after.name}}',
});
});
});
@@ -1,5 +1,7 @@
import { z } from 'zod';
import { baseTriggerSchema } from './base-trigger-schema';
import { stepFilterGroupSchema } from './step-filter-group-schema';
import { stepFilterSchema } from './step-filter-schema';
export const workflowDatabaseEventTriggerSchema = baseTriggerSchema
.extend({
@@ -22,6 +24,15 @@ export const workflowDatabaseEventTriggerSchema = baseTriggerSchema
),
objectType: z.string().optional(),
fields: z.array(z.string()).optional().nullable(),
filter: z
.object({
stepFilterGroups: z.array(stepFilterGroupSchema),
stepFilters: z.array(stepFilterSchema),
})
.optional()
.describe(
'Optional condition evaluated against the triggering record. The workflow only runs when the record matches; non-matching events are skipped before a run is created.',
),
}),
})
.describe(