From f00b6ae185f3f0ebde15338f7e9265b4dd025cfa Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:57:46 +0530 Subject: [PATCH] use Temporal for date filter evaluation and fix IS operand (#22408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Refactored `evaluateDateFilter` in the workflow filter action from native `Date` to the Temporal API, using the shared `parseToInstantOrThrow` and `isSamePlainDate` utilities from `twenty-shared` (resolving the long-standing `// TODO: refactor this with Temporal`). - Fixed a bug in the `IS` operand: it previously compared only `getDate()` (day-of-month 1–31), so e.g. `2023-01-15` incorrectly matched `2023-02-15`. It now compares the full calendar day in UTC. - Removed server-local-timezone leakage: `IS_TODAY` and day comparisons now run in UTC (consistent with the neighbouring relative-date filter util), instead of relying on `toDateString()`/`getDate()`. ## Behavior changes (intended) - `IS` now matches on the full UTC calendar day, not day-of-month. - `DATE_TIME` `IS` matches on the same UTC day (not exact-instant equality). - Date comparisons are UTC-based, so evaluations near midnight in a non-UTC server locale may differ from the old local-timezone behavior. - Parsing is stricter (ISO + known formats via `parseToInstantOrThrow`); malformed operands resolve to "no match" instead of being loosely guessed by `new Date()`. Review in cubic --- .../evaluate-filter-conditions.util.spec.ts | 118 ++++++++++++++++-- .../utils/evaluate-filter-conditions.util.ts | 70 ++++++++--- 2 files changed, 159 insertions(+), 29 deletions(-) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts index ad7adc1acf..bafe9cceb2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts @@ -887,10 +887,12 @@ describe('evaluateFilterConditions', () => { }); describe('date operands', () => { - const now = new Date(); - const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago - const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now - const today = new Date(); + const now = new Date().toISOString(); + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); // 1 day ago + const futureDate = new Date( + Date.now() + 24 * 60 * 60 * 1000, + ).toISOString(); // 1 day from now + const today = new Date().toISOString(); it('should handle IsInPast operand correctly', () => { const filter = createFilter( @@ -997,22 +999,19 @@ describe('evaluateFilterConditions', () => { }); it('should handle Is operand for dates correctly', () => { - const sameDate1 = new Date('2023-01-15'); - const sameDate2 = new Date('2023-01-15'); const filter = createFilter( ViewFilterOperand.IS, - sameDate1, - sameDate2, + '2023-01-15', + '2023-01-15', 'DATE', ); expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); - const otherDate = new Date('2023-01-16'); const differentFilter = createFilter( ViewFilterOperand.IS, - sameDate1, - otherDate, + '2023-01-15', + '2023-01-16', 'DATE', ); @@ -1021,6 +1020,103 @@ describe('evaluateFilterConditions', () => { ); }); + it('should not match Is when dates share the day of month but differ in month or year', () => { + const januaryFilter = createFilter( + ViewFilterOperand.IS, + '2023-01-15', + '2023-02-15', + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [januaryFilter] })).toBe( + false, + ); + + const yearFilter = createFilter( + ViewFilterOperand.IS, + '2023-01-15', + '2024-01-15', + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [yearFilter] })).toBe(false); + }); + + it('should match Is on the same UTC day for date-time values', () => { + const sameDayFilter = createFilter( + ViewFilterOperand.IS, + '2023-01-15T08:30:00.000Z', + '2023-01-15T21:45:00.000Z', + 'DATE_TIME', + ); + + expect(evaluateFilterConditions({ filters: [sameDayFilter] })).toBe( + true, + ); + }); + + it('should handle Date instance operands (database-event trigger passes raw ORM records)', () => { + const isFilter = createFilter( + ViewFilterOperand.IS, + new Date('2023-01-15T08:30:00.000Z'), + new Date('2023-01-15T21:45:00.000Z'), + 'DATE_TIME', + ); + + expect(evaluateFilterConditions({ filters: [isFilter] })).toBe(true); + + const beforeFilter = createFilter( + ViewFilterOperand.IS_BEFORE, + new Date('2023-01-15T00:00:00.000Z'), + new Date('2023-01-16T00:00:00.000Z'), + 'DATE_TIME', + ); + + expect(evaluateFilterConditions({ filters: [beforeFilter] })).toBe( + true, + ); + + const invalidFilter = createFilter( + ViewFilterOperand.IS_BEFORE, + new Date('invalid'), + new Date('2023-01-16T00:00:00.000Z'), + 'DATE_TIME', + ); + + expect(evaluateFilterConditions({ filters: [invalidFilter] })).toBe( + false, + ); + }); + + it('should not match Is or Before/After when the right operand is an invalid date', () => { + const isFilter = createFilter( + ViewFilterOperand.IS, + '2023-01-15', + 'not-a-date', + 'DATE', + ); + const beforeFilter = createFilter( + ViewFilterOperand.IS_BEFORE, + '2023-01-15', + 'not-a-date', + 'DATE', + ); + const afterFilter = createFilter( + ViewFilterOperand.IS_AFTER, + '2023-01-15', + 'not-a-date', + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [isFilter] })).toBe(false); + expect(evaluateFilterConditions({ filters: [beforeFilter] })).toBe( + false, + ); + expect(evaluateFilterConditions({ filters: [afterFilter] })).toBe( + false, + ); + }); + it('should handle date IsEmpty and IsNotEmpty operands', () => { const emptyFilter = createFilter( ViewFilterOperand.IS_EMPTY, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts index 566ea37773..b7db327389 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts @@ -4,6 +4,7 @@ import { isObject, isString, } from '@sniptt/guards'; +import { Temporal } from 'temporal-polyfill'; import { type StepFilter, type StepFilterGroup, @@ -13,6 +14,8 @@ import { import { convertViewFilterOperandToCoreOperand as convertViewFilterOperandDeprecated, isDefined, + isSamePlainDate, + parseToInstantOrThrow, } from 'twenty-shared/utils'; import { parseBooleanFromStringValue } from 'twenty-shared/workflow'; @@ -214,8 +217,23 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean { } } +function parseDateOperandToInstant(value: unknown): Temporal.Instant | null { + try { + if (value instanceof Date && !Number.isNaN(value.getTime())) { + return Temporal.Instant.fromEpochMilliseconds(value.getTime()); + } + + return parseToInstantOrThrow(String(value)); + } catch { + return null; + } +} + +function toUtcPlainDate(instant: Temporal.Instant): Temporal.PlainDate { + return instant.toZonedDateTimeISO('UTC').toPlainDate(); +} + function evaluateDateFilter(filter: ResolvedFilter): boolean { - // TODO: refactor this with Temporal if (filter.operand === ViewFilterOperand.IS_EMPTY) { return !isDefined(filter.leftOperand) || filter.leftOperand === ''; } @@ -224,42 +242,58 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean { return isDefined(filter.leftOperand) && filter.leftOperand !== ''; } - const dateLeftValue = new Date(String(filter.leftOperand)); + const leftInstant = parseDateOperandToInstant(filter.leftOperand); - if (Number.isNaN(dateLeftValue.getTime())) { + if (!isDefined(leftInstant)) { return false; } switch (filter.operand) { - case ViewFilterOperand.IS: + case ViewFilterOperand.IS: { + const rightInstant = parseDateOperandToInstant(filter.rightOperand); + return ( - dateLeftValue.getDate() === - new Date(String(filter.rightOperand)).getDate() + isDefined(rightInstant) && + isSamePlainDate( + toUtcPlainDate(leftInstant), + toUtcPlainDate(rightInstant), + ) ); + } + case ViewFilterOperand.IS_IN_PAST: - return dateLeftValue.getTime() < Date.now(); + return Temporal.Instant.compare(leftInstant, Temporal.Now.instant()) < 0; case ViewFilterOperand.IS_IN_FUTURE: - return dateLeftValue.getTime() > Date.now(); + return Temporal.Instant.compare(leftInstant, Temporal.Now.instant()) > 0; case ViewFilterOperand.IS_TODAY: - return dateLeftValue.toDateString() === new Date().toDateString(); - - case ViewFilterOperand.IS_BEFORE: - return ( - dateLeftValue.getTime() < - new Date(String(filter.rightOperand)).getTime() + return isSamePlainDate( + toUtcPlainDate(leftInstant), + Temporal.Now.zonedDateTimeISO('UTC').toPlainDate(), ); - case ViewFilterOperand.IS_AFTER: + case ViewFilterOperand.IS_BEFORE: { + const rightInstant = parseDateOperandToInstant(filter.rightOperand); + return ( - dateLeftValue.getTime() > - new Date(String(filter.rightOperand)).getTime() + isDefined(rightInstant) && + Temporal.Instant.compare(leftInstant, rightInstant) < 0 ); + } + + case ViewFilterOperand.IS_AFTER: { + const rightInstant = parseDateOperandToInstant(filter.rightOperand); + + return ( + isDefined(rightInstant) && + Temporal.Instant.compare(leftInstant, rightInstant) > 0 + ); + } case ViewFilterOperand.IS_RELATIVE: return parseAndEvaluateRelativeDateFilter({ - dateToCheck: dateLeftValue, + dateToCheck: new Date(leftInstant.epochMilliseconds), relativeDateString: String(filter.rightOperand), });