diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index cc3d74f..08e1916 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -758,6 +758,7 @@ export class SegmentService { 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', + 'notTriggeredWithin', ]; if (!validOperators.includes(filter.operator)) { @@ -778,6 +779,7 @@ export class SegmentService { 'olderThan', 'triggeredWithin', 'triggeredOlderThan', + 'notTriggeredWithin', ]; if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) { @@ -785,7 +787,7 @@ export class SegmentService { } // Validate unit for time-based operators - if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) { + if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator) && !filter.unit) { throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`); } } @@ -1166,6 +1168,28 @@ export class SegmentService { }, }; + case 'notTriggeredWithin': { + // Contact has not triggered this event within the timeframe (includes never-triggered contacts) + if (!unit) { + throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator'); + } + + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const since = new Date(now.getTime() - milliseconds); + + return { + events: { + none: { + name: eventName, + createdAt: { + gte: since, + }, + }, + }, + }; + } + default: throw new HttpException(400, `Unsupported operator for event field: ${operator}`); } @@ -1278,6 +1302,27 @@ export class SegmentService { }, }; + case 'notTriggeredWithin': { + // Contact has not had this email activity within the timeframe (includes contacts with no activity) + if (!unit) { + throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator'); + } + + const now = new Date(); + const milliseconds = this.getMilliseconds(value as number, unit); + const since = new Date(now.getTime() - milliseconds); + + return { + emails: { + none: { + [field]: { + gte: since, + }, + }, + }, + }; + } + default: throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`); } diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx index 97b43da..d92a15e 100644 --- a/apps/web/src/components/SegmentFilterBuilder.tsx +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, + SelectItemWithDescription, SelectTrigger, SelectValue, } from '@plunk/ui'; @@ -16,26 +17,27 @@ import {Check, ChevronsUpDown, GripVertical, Plus, Search, Trash2} from 'lucide- import {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {network} from '../lib/network'; -const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [ - {value: 'equals', label: 'Equals'}, - {value: 'notEquals', label: 'Not equals'}, - {value: 'contains', label: 'Contains'}, - {value: 'notContains', label: 'Does not contain'}, - {value: 'greaterThan', label: 'Greater than'}, - {value: 'lessThan', label: 'Less than'}, - {value: 'greaterThanOrEqual', label: 'Greater than or equal to'}, - {value: 'lessThanOrEqual', label: 'Less than or equal to'}, - {value: 'exists', label: 'Exists'}, - {value: 'notExists', label: 'Does not exist'}, - {value: 'within', label: 'Younger than (time)'}, - {value: 'olderThan', label: 'Older than (time)'}, +const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string; description: string}[] = [ + {value: 'equals', label: 'Equals', description: 'Exact match'}, + {value: 'notEquals', label: 'Not equals', description: 'Anything other than this value'}, + {value: 'contains', label: 'Contains', description: 'Value includes this text'}, + {value: 'notContains', label: 'Does not contain', description: 'Value does not include this text'}, + {value: 'greaterThan', label: 'Greater than', description: 'Value is higher than'}, + {value: 'lessThan', label: 'Less than', description: 'Value is lower than'}, + {value: 'greaterThanOrEqual', label: 'Greater than or equal', description: 'Value is at least'}, + {value: 'lessThanOrEqual', label: 'Less than or equal', description: 'Value is at most'}, + {value: 'exists', label: 'Has a value', description: 'Field is set to anything'}, + {value: 'notExists', label: 'Has no value', description: 'Field is empty or unset'}, + {value: 'within', label: 'Less than X ago', description: 'Date is within the last X days/hours'}, + {value: 'olderThan', label: 'More than X ago', description: 'Date is older than X days/hours'}, ]; -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'}, +const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string; description: string}[] = [ + {value: 'triggered', label: 'Ever occurred', description: 'This event has happened at least once'}, + {value: 'triggeredWithin', label: 'Occurred within', description: 'Happened at least once in the last X days/hours'}, + {value: 'triggeredOlderThan', label: 'Occurred, but not recently', description: 'Has happened before, but not in the last X days/hours'}, + {value: 'notTriggered', label: 'Never occurred', description: 'This event has never happened'}, + {value: 'notTriggeredWithin', label: 'Not occurred within', description: 'Has not happened in the last X days/hours — includes contacts who never triggered this'}, ]; const TIME_UNITS = [ @@ -186,7 +188,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available }, []); const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator); - const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator); + const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator); // Get field type from available fields const fieldOption = useMemo( @@ -236,7 +238,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', 'triggeredOlderThan', 'notTriggered'].includes( + const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin'].includes( filter.operator, ); @@ -273,7 +275,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', 'olderThan', 'triggeredOlderThan'].includes( + const newOperatorNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes( newOperator, ); if (!newOperatorNeedsUnit) { @@ -407,10 +409,10 @@ 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', 'olderThan', 'triggeredOlderThan'].includes( + const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes( oldOperator, ); - const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes( + const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes( newOperator, ); @@ -426,28 +428,29 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available updatedFilter.unit = undefined; } else if (newNeedsUnit && !oldNeedsUnit) { updatedFilter.unit = 'days'; - // Set default numeric value if needed - if (typeof updatedFilter.value !== 'number') { - updatedFilter.value = 7; - } + updatedFilter.value = typeof updatedFilter.value === 'number' && updatedFilter.value > 0 ? updatedFilter.value : 7; } // If switching to an operator that needs a value but we don't have one, set default - if (newNeedsValue && !oldNeedsValue) { + // Skip when entering a unit-based operator — it already set the value above + if (newNeedsValue && !oldNeedsValue && !newNeedsUnit) { updatedFilter.value = getDefaultValueForType(fieldType); } onChange(updatedFilter); }} > - + {operators.map(op => ( - - {op.label} - + ))} @@ -466,14 +469,14 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available type="number" value={filter.value as number} onChange={e => onChange({...filter, value: parseInt(e.target.value) || 0})} - className="text-sm flex-1" + className="text-sm flex-1 bg-white" min="1" /> onChange({...filter, value: e.target.value})} - className="text-sm" + className="text-sm bg-white" placeholder="Enter value" /> )} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 0fcebc7..68c4c5a 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -123,6 +123,7 @@ const segmentFilterSchema = z.object({ 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', + 'notTriggeredWithin', ]), value: z.any().optional(), unit: z.enum(['days', 'hours', 'minutes']).optional(), diff --git a/packages/types/src/segments/index.ts b/packages/types/src/segments/index.ts index 8f525b0..18ca3a0 100644 --- a/packages/types/src/segments/index.ts +++ b/packages/types/src/segments/index.ts @@ -23,7 +23,8 @@ export type SegmentFilterOperator = | '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 + | 'notTriggered' // Event/email activity never occurred + | 'notTriggeredWithin'; // Event/email activity has not occurred within timeframe (includes never-triggered) export type SegmentFilterLogic = 'AND' | 'OR';