Merge pull request #263 from 5h0ov/new-olderthan-segmentfilter

This commit is contained in:
Dries Augustyns
2026-01-13 10:30:56 +01:00
committed by GitHub
4 changed files with 125 additions and 7 deletions
+103 -1
View File
@@ -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 {
@@ -27,12 +27,14 @@ const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Within (time)'},
{value: 'within', label: 'Younger than (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,9 @@ 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 +273,9 @@ 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 +407,12 @@ 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};
+2
View File
@@ -101,8 +101,10 @@ const segmentFilterSchema = z.object({
'exists',
'notExists',
'within',
'olderThan',
'triggered',
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
]),
value: z.any().optional(),
+2
View File
@@ -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';