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
This commit is contained in:
@@ -584,8 +584,10 @@ export class SegmentService {
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggered',
|
'triggered',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
'notTriggered',
|
'notTriggered',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -604,7 +606,9 @@ export class SegmentService {
|
|||||||
'greaterThanOrEqual',
|
'greaterThanOrEqual',
|
||||||
'lessThanOrEqual',
|
'lessThanOrEqual',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||||
@@ -612,7 +616,7 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate unit for time-based operators
|
// 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)`);
|
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
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
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:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -822,6 +840,18 @@ export class SegmentService {
|
|||||||
|
|
||||||
return {[field]: {gte: since}};
|
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:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
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':
|
case 'notTriggered':
|
||||||
// Contact has never triggered this event
|
// Contact has never triggered this event
|
||||||
return {
|
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':
|
case 'notTriggered':
|
||||||
// Contact has never had this email activity
|
// Contact has never had this email activity
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,11 +28,13 @@ const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
|||||||
{value: 'exists', label: 'Exists'},
|
{value: 'exists', label: 'Exists'},
|
||||||
{value: 'notExists', label: 'Does not exist'},
|
{value: 'notExists', label: 'Does not exist'},
|
||||||
{value: 'within', label: 'Within (time)'},
|
{value: 'within', label: 'Within (time)'},
|
||||||
|
{value: 'olderThan', label: 'Older than (time)'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
||||||
{value: 'triggered', label: 'Ever occurred'},
|
{value: 'triggered', label: 'Ever occurred'},
|
||||||
{value: 'triggeredWithin', label: 'Occurred within'},
|
{value: 'triggeredWithin', label: 'Occurred within'},
|
||||||
|
{value: 'triggeredOlderThan', label: 'Occurred over (time) ago'},
|
||||||
{value: 'notTriggered', label: 'Never occurred'},
|
{value: 'notTriggered', label: 'Never occurred'},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -172,6 +174,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
].includes(op.value),
|
].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 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
|
// Get field type from available fields
|
||||||
const fieldOption = useMemo(
|
const fieldOption = useMemo(
|
||||||
@@ -217,6 +220,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
].includes(op.value),
|
].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 selectedField = availableFields.find(f => f.value === value);
|
||||||
const newFieldType = selectedField?.type || 'string';
|
const newFieldType = selectedField?.type || 'string';
|
||||||
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
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
|
// Determine default operator and value based on new field type
|
||||||
let newOperator = filter.operator;
|
let newOperator = filter.operator;
|
||||||
@@ -267,7 +271,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
newUnit = undefined;
|
newUnit = undefined;
|
||||||
|
|
||||||
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
// 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) {
|
if (!newOperatorNeedsUnit) {
|
||||||
// Convert numeric value back to appropriate type for the field
|
// Convert numeric value back to appropriate type for the field
|
||||||
newValue = getDefaultValueForType(newFieldType);
|
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
|
// Check if we're switching between operators that need different value types
|
||||||
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
|
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
|
||||||
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
|
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
|
||||||
const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator);
|
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(oldOperator);
|
||||||
const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(newOperator);
|
||||||
|
|
||||||
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
|
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
|
||||||
|
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ const segmentFilterSchema = z.object({
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggered',
|
'triggered',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
'notTriggered',
|
'notTriggered',
|
||||||
]),
|
]),
|
||||||
value: z.any().optional(),
|
value: z.any().optional(),
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ export type SegmentFilterOperator =
|
|||||||
| 'exists'
|
| 'exists'
|
||||||
| 'notExists'
|
| 'notExists'
|
||||||
| 'within'
|
| 'within'
|
||||||
|
| 'olderThan' // More than X time ago (for date fields)
|
||||||
// Event-based operators
|
// Event-based operators
|
||||||
| 'triggered' // Event/email activity occurred (any time)
|
| 'triggered' // Event/email activity occurred (any time)
|
||||||
| 'triggeredWithin' // Event/email activity occurred within timeframe
|
| 'triggeredWithin' // Event/email activity occurred within timeframe
|
||||||
|
| 'triggeredOlderThan' // Event/email activity occurred more than X time ago
|
||||||
| 'notTriggered'; // Event/email activity never occurred
|
| 'notTriggered'; // Event/email activity never occurred
|
||||||
|
|
||||||
export type SegmentFilterLogic = 'AND' | 'OR';
|
export type SegmentFilterLogic = 'AND' | 'OR';
|
||||||
|
|||||||
Reference in New Issue
Block a user