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:
+58
@@ -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);
|
||||
});
|
||||
});
|
||||
+54
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
+19
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user