fix: prevent FIND_RECORDS from silently dropping unresolved filter variables (#18814)

## Summary

Fixes #18744 — The workflow FIND_RECORDS action silently drops filter
conditions when a variable resolves to null/empty, causing the query to
return **all records** instead of erroring.

**Root cause (three compounding layers):**

1. **`variable-resolver.ts`** — `resolveString` returns `undefined` when
a variable lookup fails (e.g., `{{steps.trigger.output.userId}}` where
`userId` doesn't exist in context). The return type says `string` but
`evalFromContext` actually returns `undefined` at runtime.

2. **`checkIfShouldSkipFiltering.ts`** — Treats `undefined`/`null`/`""`
values as "skip this filter." This is correct for the **UI filter
builder** (user hasn't finished typing), but wrong for **workflow
execution** (variable resolution failed = misconfigured workflow).

3. **`find-records.workflow-action.ts`** — When all filters are silently
skipped, `computeRecordGqlOperationFilter` returns `{}` (match
everything). The query runs with no filter, returning all records —
silently succeeding with wrong results.

## Fix

Added validation in `find-records.workflow-action.ts` **after**
`resolveInput` but **before** `computeRecordGqlOperationFilter`. For
each filter with a value-requiring operand (i.e., not IS_EMPTY,
IS_NOT_EMPTY, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY), if the resolved value
is `undefined`, `null`, or `""`, throw `INVALID_STEP_INPUT` with a
descriptive error message.

**Why this approach:**
- Scoped to the workflow executor — does **not** break the UI filter
builder's intentional skip-on-empty behavior
- Does not change shared utilities (`checkIfShouldSkipFiltering`,
`resolveInput`) used across the app
- Fails fast with a clear error instead of silently returning wrong data
- 1 file changed, 23 lines added

## Test plan

- [x] Backend typecheck passes
- [x] oxlint passes (0 warnings, 0 errors)
- [x] Prettier passes
- [ ] Manual: Create a workflow with FIND_RECORDS using a variable that
doesn't exist → should error with "Filter condition has an empty value
after variable resolution" instead of returning all records
- [ ] Manual: Create a workflow with FIND_RECORDS using IS_EMPTY operand
(no value needed) → should still work correctly

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
oniani1
2026-03-21 21:27:01 +04:00
committed by GitHub
parent 03a2abb305
commit d69e4d7008
16 changed files with 370 additions and 92 deletions
@@ -1,8 +1,8 @@
import { ViewFilterOperand } from 'twenty-shared/types';
import { isFilterOperandExpectingValue } from '@/object-record/object-filter-dropdown/utils/isFilterOperandExpectingValue';
import { isRecordFilterOperandExpectingValue } from 'twenty-shared/utils';
describe('isFilterOperandExpectingValue', () => {
describe('isRecordFilterOperandExpectingValue', () => {
const testCases = [
{ operand: ViewFilterOperand.CONTAINS, expectedResult: true },
{ operand: ViewFilterOperand.DOES_NOT_CONTAIN, expectedResult: true },
@@ -24,7 +24,7 @@ describe('isFilterOperandExpectingValue', () => {
testCases.forEach(({ operand, expectedResult }) => {
it(`should return ${expectedResult} for ViewFilterOperand.${operand}`, () => {
expect(isFilterOperandExpectingValue(operand)).toBe(expectedResult);
expect(isRecordFilterOperandExpectingValue(operand)).toBe(expectedResult);
});
});
});
@@ -1,24 +0,0 @@
import { ViewFilterOperand } from 'twenty-shared/types';
export const isFilterOperandExpectingValue = (operand: ViewFilterOperand) => {
switch (operand) {
case ViewFilterOperand.IS_NOT_NULL:
case ViewFilterOperand.IS_EMPTY:
case ViewFilterOperand.IS_NOT_EMPTY:
case ViewFilterOperand.IS_IN_PAST:
case ViewFilterOperand.IS_IN_FUTURE:
case ViewFilterOperand.IS_TODAY:
return false;
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.CONTAINS:
case ViewFilterOperand.DOES_NOT_CONTAIN:
case ViewFilterOperand.GREATER_THAN_OR_EQUAL:
case ViewFilterOperand.LESS_THAN_OR_EQUAL:
case ViewFilterOperand.IS_BEFORE:
case ViewFilterOperand.IS_AFTER:
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_RELATIVE:
default:
return true;
}
};
@@ -1,24 +1,8 @@
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isDefined } from 'twenty-shared/utils';
import { isRecordFilterValueValid } from 'twenty-shared/utils';
export const isRecordFilterConsideredEmpty = (
recordFilter: RecordFilter,
): boolean => {
const { value, operand } = recordFilter;
if (
(!isDefined(value) || value === '' || value === '[]') &&
![
RecordFilterOperand.IS_EMPTY,
RecordFilterOperand.IS_NOT_EMPTY,
RecordFilterOperand.IS_IN_PAST,
RecordFilterOperand.IS_IN_FUTURE,
RecordFilterOperand.IS_TODAY,
].includes(operand)
) {
return true;
}
return false;
return !isRecordFilterValueValid(recordFilter);
};
@@ -0,0 +1,47 @@
import type { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import {
RowLevelPermissionPredicateOperand,
ViewFilterOperand,
} from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
export const mapRLSOperandToRecordFilterOperand = (
operand: RowLevelPermissionPredicateOperand,
): RecordFilterOperand => {
switch (operand) {
case RowLevelPermissionPredicateOperand.IS:
return ViewFilterOperand.IS;
case RowLevelPermissionPredicateOperand.IS_NOT_NULL:
return ViewFilterOperand.IS_NOT_NULL;
case RowLevelPermissionPredicateOperand.IS_NOT:
return ViewFilterOperand.IS_NOT;
case RowLevelPermissionPredicateOperand.LESS_THAN_OR_EQUAL:
return ViewFilterOperand.LESS_THAN_OR_EQUAL;
case RowLevelPermissionPredicateOperand.GREATER_THAN_OR_EQUAL:
return ViewFilterOperand.GREATER_THAN_OR_EQUAL;
case RowLevelPermissionPredicateOperand.IS_BEFORE:
return ViewFilterOperand.IS_BEFORE;
case RowLevelPermissionPredicateOperand.IS_AFTER:
return ViewFilterOperand.IS_AFTER;
case RowLevelPermissionPredicateOperand.CONTAINS:
return ViewFilterOperand.CONTAINS;
case RowLevelPermissionPredicateOperand.DOES_NOT_CONTAIN:
return ViewFilterOperand.DOES_NOT_CONTAIN;
case RowLevelPermissionPredicateOperand.IS_EMPTY:
return ViewFilterOperand.IS_EMPTY;
case RowLevelPermissionPredicateOperand.IS_NOT_EMPTY:
return ViewFilterOperand.IS_NOT_EMPTY;
case RowLevelPermissionPredicateOperand.IS_RELATIVE:
return ViewFilterOperand.IS_RELATIVE;
case RowLevelPermissionPredicateOperand.IS_IN_PAST:
return ViewFilterOperand.IS_IN_PAST;
case RowLevelPermissionPredicateOperand.IS_IN_FUTURE:
return ViewFilterOperand.IS_IN_FUTURE;
case RowLevelPermissionPredicateOperand.IS_TODAY:
return ViewFilterOperand.IS_TODAY;
case RowLevelPermissionPredicateOperand.VECTOR_SEARCH:
return ViewFilterOperand.VECTOR_SEARCH;
default:
assertUnreachable(operand);
}
};