feat: add segment membership operators and enhance segment filter functionality

This commit is contained in:
Dries Augustyns
2026-05-04 19:07:04 +02:00
parent 2d05c3fbc1
commit a3cc62213f
6 changed files with 184 additions and 40 deletions
+2 -2
View File
@@ -780,7 +780,7 @@ export class CampaignService {
} }
// Use the SegmentService to build the where clause from the condition // Use the SegmentService to build the where clause from the condition
const segmentWhere = SegmentService.buildConditionClause(condition); const segmentWhere = await SegmentService.buildConditionClause(condition);
return { return {
...baseWhere, ...baseWhere,
@@ -823,7 +823,7 @@ export class CampaignService {
} }
const condition = fromPrismaJson<FilterCondition>(segment.condition); const condition = fromPrismaJson<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition); const segmentWhere = await SegmentService.buildConditionClause(condition);
return { return {
...baseWhere, ...baseWhere,
+110 -14
View File
@@ -91,7 +91,7 @@ export class SegmentService {
} }
const condition = fromPrismaJson<FilterCondition>(segment.condition); const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = await this.buildWhereClause(projectId, condition);
const [contacts, total] = await Promise.all([ const [contacts, total] = await Promise.all([
prisma.contact.findMany({ prisma.contact.findMany({
@@ -137,7 +137,7 @@ export class SegmentService {
this.validateCondition(data.condition); this.validateCondition(data.condition);
// Compute initial member count // Compute initial member count
const where = this.buildWhereClause(projectId, data.condition); const where = await this.buildWhereClause(projectId, data.condition);
memberCount = await prisma.contact.count({where}); memberCount = await prisma.contact.count({where});
conditionJson = toPrismaJson(data.condition); conditionJson = toPrismaJson(data.condition);
} }
@@ -192,10 +192,14 @@ export class SegmentService {
if (data.condition !== undefined && existing.type !== 'STATIC') { if (data.condition !== undefined && existing.type !== 'STATIC') {
// Validate condition if provided (only for DYNAMIC segments) // Validate condition if provided (only for DYNAMIC segments)
this.validateCondition(data.condition); this.validateCondition(data.condition);
if (this.getReferencedSegmentIds(data.condition).has(segmentId)) {
throw new HttpException(400, 'A segment cannot reference itself');
}
updateData.condition = toPrismaJson(data.condition); updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes // Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition); const where = await this.buildWhereClause(projectId, data.condition);
updateData.memberCount = await prisma.contact.count({where}); updateData.memberCount = await prisma.contact.count({where});
} }
if (data.trackMembership !== undefined) { if (data.trackMembership !== undefined) {
@@ -267,7 +271,7 @@ export class SegmentService {
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}); memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
} else { } else {
const condition = fromPrismaJson<FilterCondition>(segment.condition); const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = await this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where}); memberCount = await prisma.contact.count({where});
} }
@@ -305,7 +309,7 @@ export class SegmentService {
}); });
} else { } else {
const condition = fromPrismaJson<FilterCondition>(segment.condition); const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = await this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where}); memberCount = await prisma.contact.count({where});
} }
@@ -460,7 +464,7 @@ export class SegmentService {
} }
const condition = fromPrismaJson<FilterCondition>(segment.condition); const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = await this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues // Get all matching contacts using cursor-based pagination to avoid memory issues
const BATCH_SIZE = 1000; const BATCH_SIZE = 1000;
@@ -629,9 +633,57 @@ export class SegmentService {
/** /**
* Build a single filter condition * Build a single filter condition
*/ */
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput { public static async buildFilterCondition(
filter: SegmentFilter,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const {field, operator, value, unit} = filter; const {field, operator, value, unit} = filter;
// Handle segment membership filters (e.g., "segment.<segmentId>")
if (field.startsWith('segment.')) {
const segmentId = field.substring(8);
if (visitedSegments.has(segmentId)) {
throw new HttpException(400, 'Circular segment reference detected');
}
const referencedSegment = await prisma.segment.findUnique({
where: {id: segmentId},
});
if (!referencedSegment) {
throw new HttpException(400, `Referenced segment not found: ${segmentId}`);
}
let memberIds: string[];
if (referencedSegment.type === 'STATIC' || referencedSegment.trackMembership) {
// Use the membership table for static or tracked segments
const memberships = await prisma.segmentMembership.findMany({
where: {segmentId, exitedAt: null},
select: {contactId: true},
});
memberIds = memberships.map(m => m.contactId);
} else {
// For untracked dynamic segments, evaluate the condition recursively
const nestedCondition = fromPrismaJson<FilterCondition>(referencedSegment.condition);
const nextVisited = new Set(visitedSegments).add(segmentId);
const nestedWhere = await this.buildConditionClause(nestedCondition, nextVisited);
if (operator === 'memberOfSegment') {
return nestedWhere;
}
return {NOT: nestedWhere};
}
if (operator === 'memberOfSegment') {
return {id: {in: memberIds}};
}
return {id: {notIn: memberIds}};
}
// Handle event-based filters (e.g., "event.upgrade", "event.purchase") // Handle event-based filters (e.g., "event.upgrade", "event.purchase")
if (field.startsWith('event.')) { if (field.startsWith('event.')) {
const eventName = field.substring(6); // Remove "event." prefix const eventName = field.substring(6); // Remove "event." prefix
@@ -664,6 +716,26 @@ export class SegmentService {
} }
} }
/**
* Collect all segment IDs directly referenced in a condition (non-recursive DB lookup)
*/
private static getReferencedSegmentIds(condition: FilterCondition): Set<string> {
const ids = new Set<string>();
for (const group of condition.groups) {
for (const filter of group.filters) {
if (filter.field.startsWith('segment.')) {
ids.add(filter.field.substring(8));
}
}
if (group.conditions) {
for (const id of this.getReferencedSegmentIds(group.conditions)) {
ids.add(id);
}
}
}
return ids;
}
/** /**
* Validate segment condition (recursive) * Validate segment condition (recursive)
*/ */
@@ -688,8 +760,13 @@ export class SegmentService {
/** /**
* Build Prisma clause from filter condition (recursive) * Build Prisma clause from filter condition (recursive)
*/ */
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput { public static async buildConditionClause(
const groupClauses = condition.groups.map(group => this.buildGroupClause(group)); condition: FilterCondition,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const groupClauses = await Promise.all(
condition.groups.map(group => this.buildGroupClause(group, visitedSegments)),
);
if (condition.logic === 'AND') { if (condition.logic === 'AND') {
return {AND: groupClauses}; return {AND: groupClauses};
@@ -759,12 +836,22 @@ export class SegmentService {
'triggeredOlderThan', 'triggeredOlderThan',
'notTriggered', 'notTriggered',
'notTriggeredWithin', 'notTriggeredWithin',
'memberOfSegment',
'notMemberOfSegment',
]; ];
if (!validOperators.includes(filter.operator)) { if (!validOperators.includes(filter.operator)) {
throw new HttpException(400, `Invalid operator: ${filter.operator}`); throw new HttpException(400, `Invalid operator: ${filter.operator}`);
} }
// Segment membership operators use the segmentId encoded in the field name, no separate value needed
if (filter.operator === 'memberOfSegment' || filter.operator === 'notMemberOfSegment') {
if (!filter.field.startsWith('segment.')) {
throw new HttpException(400, 'memberOfSegment/notMemberOfSegment operators require a segment field (segment.<id>)');
}
return;
}
// Validate that operators that need a value have one // Validate that operators that need a value have one
const operatorsNeedingValue = [ const operatorsNeedingValue = [
'equals', 'equals',
@@ -795,27 +882,36 @@ export class SegmentService {
/** /**
* Build Prisma where clause from filter condition (entry point) * Build Prisma where clause from filter condition (entry point)
*/ */
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput { private static async buildWhereClause(
projectId: string,
condition: FilterCondition,
): Promise<Prisma.ContactWhereInput> {
return { return {
projectId, projectId,
...this.buildConditionClause(condition), ...(await this.buildConditionClause(condition)),
}; };
} }
/** /**
* Build Prisma clause from filter group (recursive) * Build Prisma clause from filter group (recursive)
*/ */
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput { private static async buildGroupClause(
group: FilterGroup,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const clauses: Prisma.ContactWhereInput[] = []; const clauses: Prisma.ContactWhereInput[] = [];
// Add filter conditions from this group // Add filter conditions from this group
if (group.filters.length > 0) { if (group.filters.length > 0) {
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter))); const filterClauses = await Promise.all(
group.filters.map(filter => this.buildFilterCondition(filter, visitedSegments)),
);
clauses.push(...filterClauses);
} }
// Add nested condition if present // Add nested condition if present
if (group.conditions) { if (group.conditions) {
clauses.push(this.buildConditionClause(group.conditions)); clauses.push(await this.buildConditionClause(group.conditions, visitedSegments));
} }
// All conditions within a group are combined with AND // All conditions within a group are combined with AND
@@ -40,6 +40,11 @@ const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string; description
{value: 'notTriggeredWithin', label: 'Not occurred within', description: 'Has not happened in the last X days/hours — includes contacts who never triggered this'}, {value: 'notTriggeredWithin', label: 'Not occurred within', description: 'Has not happened in the last X days/hours — includes contacts who never triggered this'},
]; ];
const SEGMENT_OPERATORS: {value: SegmentFilterOperator; label: string; description: string}[] = [
{value: 'memberOfSegment', label: 'Is member of', description: 'Contact is currently in this segment'},
{value: 'notMemberOfSegment', label: 'Is not member of', description: 'Contact is not in this segment'},
];
const TIME_UNITS = [ const TIME_UNITS = [
{value: 'minutes', label: 'Minutes'}, {value: 'minutes', label: 'Minutes'},
{value: 'hours', label: 'Hours'}, {value: 'hours', label: 'Hours'},
@@ -57,25 +62,27 @@ const STANDARD_FIELDS = [
interface FieldOption { interface FieldOption {
value: string; value: string;
label: string; label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email'; description?: string;
category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity'; type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email' | 'segment';
category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity' | 'Segments';
} }
// Hook to fetch available fields and events // Hook to fetch available fields, events, and segments
function useAvailableOptions() { function useAvailableOptions(currentSegmentId?: string) {
const [fields, setFields] = useState<FieldOption[]>([...STANDARD_FIELDS]); const [fields, setFields] = useState<FieldOption[]>([...STANDARD_FIELDS]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchOptions = async () => { const fetchOptions = async () => {
try { try {
// Fetch contact fields with types // Fetch contact fields with types, event names, and segments in parallel
const fieldsData = await network.fetch<{ const [fieldsData, eventsData, segmentsData] = await Promise.all([
fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>; network.fetch<{
}>('GET', '/contacts/fields'); fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>;
}>('GET', '/contacts/fields'),
// Fetch event names network.fetch<{eventNames: string[]}>('GET', '/events/names'),
const eventsData = await network.fetch<{eventNames: string[]}>('GET', '/events/names'); network.fetch<Array<{id: string; name: string; memberCount: number}>>('GET', '/segments'),
]);
// Build field options from typed fields // Build field options from typed fields
const typedFields: FieldOption[] = (fieldsData.fields || []).map(f => { const typedFields: FieldOption[] = (fieldsData.fields || []).map(f => {
@@ -115,7 +122,18 @@ function useAvailableOptions() {
} }
}); });
setFields([...typedFields, ...eventOptions, ...emailOptions]); // Build segment options, excluding the current segment to prevent self-reference
const segmentOptions: FieldOption[] = (segmentsData || [])
.filter((s: {id: string; name: string; memberCount: number}) => s.id !== currentSegmentId)
.map((s: {id: string; name: string; memberCount: number}) => ({
value: `segment.${s.id}`,
label: s.name,
description: `${s.memberCount.toLocaleString()} ${s.memberCount === 1 ? 'person' : 'people'}`,
type: 'segment' as const,
category: 'Segments' as const,
}));
setFields([...typedFields, ...eventOptions, ...emailOptions, ...segmentOptions]);
} catch (error) { } catch (error) {
console.error('Failed to fetch available fields and events:', error); console.error('Failed to fetch available fields and events:', error);
} finally { } finally {
@@ -124,7 +142,7 @@ function useAvailableOptions() {
}; };
fetchOptions(); fetchOptions();
}, []); }, [currentSegmentId]);
return {fields, loading}; return {fields, loading};
} }
@@ -156,6 +174,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
// Helper to get valid operators for a field type // Helper to get valid operators for a field type
const getOperatorsForType = useCallback((type: string, isEvent: boolean) => { const getOperatorsForType = useCallback((type: string, isEvent: boolean) => {
if (type === 'segment') {
return SEGMENT_OPERATORS;
}
if (isEvent) { if (isEvent) {
return EVENT_OPERATORS; return EVENT_OPERATORS;
} }
@@ -187,7 +209,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', 'memberOfSegment', 'notMemberOfSegment'].includes(filter.operator);
const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator); const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator);
// Get field type from available fields // Get field type from available fields
@@ -198,9 +220,14 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
const fieldType = fieldOption?.type || 'string'; const fieldType = fieldOption?.type || 'string';
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email'; const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
const isSegment = fieldType === 'segment';
// Get operators based on field type (memoized) // Get operators based on field type (memoized)
const operators = useMemo(() => { const operators = useMemo(() => {
if (isSegment) {
return SEGMENT_OPERATORS;
}
if (isEventOrEmailActivity) { if (isEventOrEmailActivity) {
return EVENT_OPERATORS; return EVENT_OPERATORS;
} }
@@ -231,23 +258,35 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
return STANDARD_OPERATORS.filter(op => return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value), ['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value),
); );
}, [fieldType, isEventOrEmailActivity]); }, [fieldType, isEventOrEmailActivity, isSegment]);
const handleFieldChange = useCallback( const handleFieldChange = useCallback(
(value: string) => { (value: string) => {
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 isNewSegment = newFieldType === 'segment';
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin'].includes( const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin'].includes(
filter.operator, filter.operator,
); );
const currentOperatorIsSegment = ['memberOfSegment', 'notMemberOfSegment'].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;
let newValue: string | number | boolean | undefined = undefined; let newValue: string | number | boolean | undefined = undefined;
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined; let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
if (isEvent && !currentOperatorIsEvent) { if (isNewSegment && !currentOperatorIsSegment) {
// Switching to segment field
newOperator = 'memberOfSegment';
newValue = undefined;
newUnit = undefined;
} else if (!isNewSegment && currentOperatorIsSegment) {
// Switching from segment to non-segment field
newOperator = isEvent ? 'triggered' : 'equals';
newValue = isEvent ? undefined : getDefaultValueForType(newFieldType);
newUnit = undefined;
} else if (isEvent && !currentOperatorIsEvent) {
// Switching to event field // Switching to event field
newOperator = 'triggered'; newOperator = 'triggered';
newValue = undefined; newValue = undefined;
@@ -383,9 +422,11 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
{field.type} {field.type}
</span> </span>
</div> </div>
{field.value !== field.label && ( {field.description ? (
<span className="text-xs text-neutral-500">{field.description}</span>
) : field.value !== field.label ? (
<span className="text-xs text-neutral-500">{field.value}</span> <span className="text-xs text-neutral-500">{field.value}</span>
)} ) : null}
</div> </div>
</button> </button>
))} ))}
@@ -407,8 +448,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
const oldOperator = filter.operator; const oldOperator = filter.operator;
// 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 noValueOperators = ['exists', 'notExists', 'triggered', 'notTriggered', 'memberOfSegment', 'notMemberOfSegment'];
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator); const oldNeedsValue = !noValueOperators.includes(oldOperator);
const newNeedsValue = !noValueOperators.includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes( const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(
oldOperator, oldOperator,
); );
@@ -761,10 +803,11 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
interface SegmentFilterBuilderProps { interface SegmentFilterBuilderProps {
condition: FilterCondition; condition: FilterCondition;
onChange: (condition: FilterCondition) => void; onChange: (condition: FilterCondition) => void;
currentSegmentId?: string;
} }
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) { export function SegmentFilterBuilder({condition, onChange, currentSegmentId}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions(); const {fields, loading} = useAvailableOptions(currentSegmentId);
if (loading) { if (loading) {
return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>; return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>;
+1 -1
View File
@@ -297,7 +297,7 @@ export default function SegmentDetailPage() {
<CardDescription>Build complex audience filters with AND/OR logic</CardDescription> <CardDescription>Build complex audience filters with AND/OR logic</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<SegmentFilterBuilder condition={condition} onChange={setCondition} /> <SegmentFilterBuilder condition={condition} onChange={setCondition} currentSegmentId={id as string} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
+2
View File
@@ -124,6 +124,8 @@ const segmentFilterSchema = z.object({
'triggeredOlderThan', 'triggeredOlderThan',
'notTriggered', 'notTriggered',
'notTriggeredWithin', 'notTriggeredWithin',
'memberOfSegment',
'notMemberOfSegment',
]), ]),
value: z.any().optional(), value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(), unit: z.enum(['days', 'hours', 'minutes']).optional(),
+4 -1
View File
@@ -24,7 +24,10 @@ export type SegmentFilterOperator =
| 'triggeredWithin' // Event/email activity occurred within timeframe | 'triggeredWithin' // Event/email activity occurred within timeframe
| 'triggeredOlderThan' // Event/email activity occurred more than X time ago | '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) | 'notTriggeredWithin' // Event/email activity has not occurred within timeframe (includes never-triggered)
// Segment membership operators
| 'memberOfSegment' // Contact is a member of another segment
| 'notMemberOfSegment'; // Contact is not a member of another segment
export type SegmentFilterLogic = 'AND' | 'OR'; export type SegmentFilterLogic = 'AND' | 'OR';