From b5fa21a57cbf491e0438eb29fc9419063fa2aea7 Mon Sep 17 00:00:00 2001 From: Shuvadipta Das Date: Mon, 12 Jan 2026 11:10:53 +0530 Subject: [PATCH] feat: add "olderThan" segment filter operator - Added `olderThan` and `triggeredOlderThan` operators to types and schemas - Implemented backend logic for `olderThan` operator in SegmentService - Updated SegmentFilterBuilder and shared index/types files to support new time-based operators --- apps/api/src/services/SegmentService.ts | 104 +++++++++++++++++- .../src/components/SegmentFilterBuilder.tsx | 14 ++- packages/shared/src/schemas/index.ts | 2 + packages/types/src/segments/index.ts | 2 + 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 301ef4e..962ddf7 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -584,8 +584,10 @@ export class SegmentService { 'exists', 'notExists', 'within', + 'olderThan', 'triggered', 'triggeredWithin', + 'triggeredOlderThan', 'notTriggered', ]; @@ -604,7 +606,9 @@ export class SegmentService { 'greaterThanOrEqual', 'lessThanOrEqual', 'within', + 'olderThan', 'triggeredWithin', + 'triggeredOlderThan', ]; if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) { @@ -612,7 +616,7 @@ export class SegmentService { } // Validate unit for time-based operators - if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) { + if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) { throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`); } } @@ -729,6 +733,20 @@ export class SegmentService { // Use ISO string for lexicographic comparison in JSON return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}}; } + case 'olderThan': { + // Note: Requires JSON date fields in ISO 8601 format for proper comparison + if (!unit) { + throw new HttpException(400, 'Unit is required for "olderThan" operator'); + } + + // Calculate the "before" date (X time units ago from now) + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const before = new Date(now.getTime() - milliseconds); + + // Use ISO string for lexicographic comparison in JSON + return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}}; + } default: throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`); } @@ -822,6 +840,18 @@ export class SegmentService { return {[field]: {gte: since}}; } + case 'olderThan': { + // "olderThan X days/hours/minutes" means more than X time units ago + if (!unit) { + throw new HttpException(400, 'Unit is required for "olderThan" operator'); + } + + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const before = new Date(now.getTime() - milliseconds); + + return {[field]: {lt: before}}; + } default: throw new HttpException(400, `Unsupported operator for date field: ${operator}`); } @@ -921,6 +951,42 @@ export class SegmentService { }; } + case 'triggeredOlderThan': { + // Contact triggered this event, but only more than X time ago (not recently) + // This means: has event AND all occurrences are before the cutoff + if (!unit) { + throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator'); + } + + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const before = new Date(now.getTime() - milliseconds); + + return { + AND: [ + // Must have triggered the event at some point + { + events: { + some: { + name: eventName, + }, + }, + }, + // But NOT within the recent timeframe + { + events: { + none: { + name: eventName, + createdAt: { + gte: before, + }, + }, + }, + }, + ], + }; + } + case 'notTriggered': // Contact has never triggered this event return { @@ -995,6 +1061,42 @@ export class SegmentService { }; } + case 'triggeredOlderThan': { + // Contact had this email activity, but only more than X time ago (not recently) + if (!unit) { + throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator'); + } + + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const before = new Date(now.getTime() - milliseconds); + + return { + AND: [ + // Must have the email activity at some point + { + emails: { + some: { + [field]: { + not: null, + }, + }, + }, + }, + // But NOT within the recent timeframe + { + emails: { + none: { + [field]: { + gte: before, + }, + }, + }, + }, + ], + }; + } + case 'notTriggered': // Contact has never had this email activity return { diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx index e23ebad..d7d4791 100644 --- a/apps/web/src/components/SegmentFilterBuilder.tsx +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -28,11 +28,13 @@ const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [ {value: 'exists', label: 'Exists'}, {value: 'notExists', label: 'Does not exist'}, {value: 'within', label: 'Within (time)'}, + {value: 'olderThan', label: 'Older than (time)'}, ]; const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [ {value: 'triggered', label: 'Ever occurred'}, {value: 'triggeredWithin', label: 'Occurred within'}, + {value: 'triggeredOlderThan', label: 'Occurred over (time) ago'}, {value: 'notTriggered', label: 'Never occurred'}, ]; @@ -172,6 +174,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available 'exists', 'notExists', 'within', + 'olderThan', ].includes(op.value), ); } @@ -183,7 +186,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available }, []); const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator); - const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator); + const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator); // Get field type from available fields const fieldOption = useMemo( @@ -217,6 +220,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available 'exists', 'notExists', 'within', + 'olderThan', ].includes(op.value), ); } @@ -232,7 +236,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available const selectedField = availableFields.find(f => f.value === value); const newFieldType = selectedField?.type || 'string'; const isEvent = newFieldType === 'event' || newFieldType === 'email'; - const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator); + const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered'].includes(filter.operator); // Determine default operator and value based on new field type let newOperator = filter.operator; @@ -267,7 +271,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available newUnit = undefined; // If the new operator doesn't support units but we had them, ensure value is appropriate - const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator); + const newOperatorNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(newOperator); if (!newOperatorNeedsUnit) { // Convert numeric value back to appropriate type for the field newValue = getDefaultValueForType(newFieldType); @@ -399,8 +403,8 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available // Check if we're switching between operators that need different value types const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator); const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator); - const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator); - const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator); + const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(oldOperator); + const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(newOperator); const updatedFilter: SegmentFilter = {...filter, operator: newOperator}; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index f734b72..ca76b85 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -101,8 +101,10 @@ const segmentFilterSchema = z.object({ 'exists', 'notExists', 'within', + 'olderThan', 'triggered', 'triggeredWithin', + 'triggeredOlderThan', 'notTriggered', ]), value: z.any().optional(), diff --git a/packages/types/src/segments/index.ts b/packages/types/src/segments/index.ts index 6375670..bde3d2c 100644 --- a/packages/types/src/segments/index.ts +++ b/packages/types/src/segments/index.ts @@ -16,9 +16,11 @@ export type SegmentFilterOperator = | 'exists' | 'notExists' | 'within' + | 'olderThan' // More than X time ago (for date fields) // Event-based operators | 'triggered' // Event/email activity occurred (any time) | 'triggeredWithin' // Event/email activity occurred within timeframe + | 'triggeredOlderThan' // Event/email activity occurred more than X time ago | 'notTriggered'; // Event/email activity never occurred export type SegmentFilterLogic = 'AND' | 'OR';