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:
+32
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user