From a3cc62213f40f6a9341113b73b52852292fb9a10 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 4 May 2026 19:07:04 +0200 Subject: [PATCH] feat: add segment membership operators and enhance segment filter functionality --- apps/api/src/services/CampaignService.ts | 4 +- apps/api/src/services/SegmentService.ts | 124 ++++++++++++++++-- .../src/components/SegmentFilterBuilder.tsx | 87 ++++++++---- apps/web/src/pages/segments/[id].tsx | 2 +- packages/shared/src/schemas/index.ts | 2 + packages/types/src/segments/index.ts | 5 +- 6 files changed, 184 insertions(+), 40 deletions(-) diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 93592b5..809c66f 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -780,7 +780,7 @@ export class CampaignService { } // Use the SegmentService to build the where clause from the condition - const segmentWhere = SegmentService.buildConditionClause(condition); + const segmentWhere = await SegmentService.buildConditionClause(condition); return { ...baseWhere, @@ -823,7 +823,7 @@ export class CampaignService { } const condition = fromPrismaJson(segment.condition); - const segmentWhere = SegmentService.buildConditionClause(condition); + const segmentWhere = await SegmentService.buildConditionClause(condition); return { ...baseWhere, diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index 08e1916..1f0e473 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -91,7 +91,7 @@ export class SegmentService { } const condition = fromPrismaJson(segment.condition); - const where = this.buildWhereClause(projectId, condition); + const where = await this.buildWhereClause(projectId, condition); const [contacts, total] = await Promise.all([ prisma.contact.findMany({ @@ -137,7 +137,7 @@ export class SegmentService { this.validateCondition(data.condition); // 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}); conditionJson = toPrismaJson(data.condition); } @@ -192,10 +192,14 @@ export class SegmentService { if (data.condition !== undefined && existing.type !== 'STATIC') { // Validate condition if provided (only for DYNAMIC segments) 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); // 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}); } if (data.trackMembership !== undefined) { @@ -267,7 +271,7 @@ export class SegmentService { memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}); } else { const condition = fromPrismaJson(segment.condition); - const where = this.buildWhereClause(projectId, condition); + const where = await this.buildWhereClause(projectId, condition); memberCount = await prisma.contact.count({where}); } @@ -305,7 +309,7 @@ export class SegmentService { }); } else { const condition = fromPrismaJson(segment.condition); - const where = this.buildWhereClause(projectId, condition); + const where = await this.buildWhereClause(projectId, condition); memberCount = await prisma.contact.count({where}); } @@ -460,7 +464,7 @@ export class SegmentService { } const condition = fromPrismaJson(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 const BATCH_SIZE = 1000; @@ -629,9 +633,57 @@ export class SegmentService { /** * Build a single filter condition */ - public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput { + public static async buildFilterCondition( + filter: SegmentFilter, + visitedSegments = new Set(), + ): Promise { const {field, operator, value, unit} = filter; + // Handle segment membership filters (e.g., "segment.") + 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(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") if (field.startsWith('event.')) { 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 { + const ids = new Set(); + 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) */ @@ -688,8 +760,13 @@ export class SegmentService { /** * Build Prisma clause from filter condition (recursive) */ - public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput { - const groupClauses = condition.groups.map(group => this.buildGroupClause(group)); + public static async buildConditionClause( + condition: FilterCondition, + visitedSegments = new Set(), + ): Promise { + const groupClauses = await Promise.all( + condition.groups.map(group => this.buildGroupClause(group, visitedSegments)), + ); if (condition.logic === 'AND') { return {AND: groupClauses}; @@ -759,12 +836,22 @@ export class SegmentService { 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin', + 'memberOfSegment', + 'notMemberOfSegment', ]; if (!validOperators.includes(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.)'); + } + return; + } + // Validate that operators that need a value have one const operatorsNeedingValue = [ 'equals', @@ -795,27 +882,36 @@ export class SegmentService { /** * 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 { return { projectId, - ...this.buildConditionClause(condition), + ...(await this.buildConditionClause(condition)), }; } /** * Build Prisma clause from filter group (recursive) */ - private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput { + private static async buildGroupClause( + group: FilterGroup, + visitedSegments = new Set(), + ): Promise { const clauses: Prisma.ContactWhereInput[] = []; // Add filter conditions from this group 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 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 diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx index d92a15e..fc8d7ab 100644 --- a/apps/web/src/components/SegmentFilterBuilder.tsx +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -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'}, ]; +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 = [ {value: 'minutes', label: 'Minutes'}, {value: 'hours', label: 'Hours'}, @@ -57,25 +62,27 @@ const STANDARD_FIELDS = [ interface FieldOption { value: string; label: string; - type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email'; - category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity'; + description?: string; + type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email' | 'segment'; + category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity' | 'Segments'; } -// Hook to fetch available fields and events -function useAvailableOptions() { +// Hook to fetch available fields, events, and segments +function useAvailableOptions(currentSegmentId?: string) { const [fields, setFields] = useState([...STANDARD_FIELDS]); const [loading, setLoading] = useState(true); useEffect(() => { const fetchOptions = async () => { try { - // Fetch contact fields with types - const fieldsData = await network.fetch<{ - fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>; - }>('GET', '/contacts/fields'); - - // Fetch event names - const eventsData = await network.fetch<{eventNames: string[]}>('GET', '/events/names'); + // Fetch contact fields with types, event names, and segments in parallel + const [fieldsData, eventsData, segmentsData] = await Promise.all([ + network.fetch<{ + fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>; + }>('GET', '/contacts/fields'), + network.fetch<{eventNames: string[]}>('GET', '/events/names'), + network.fetch>('GET', '/segments'), + ]); // Build field options from typed fields 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) { console.error('Failed to fetch available fields and events:', error); } finally { @@ -124,7 +142,7 @@ function useAvailableOptions() { }; fetchOptions(); - }, []); + }, [currentSegmentId]); 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 const getOperatorsForType = useCallback((type: string, isEvent: boolean) => { + if (type === 'segment') { + return SEGMENT_OPERATORS; + } + if (isEvent) { 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); // 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 isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email'; + const isSegment = fieldType === 'segment'; // Get operators based on field type (memoized) const operators = useMemo(() => { + if (isSegment) { + return SEGMENT_OPERATORS; + } + if (isEventOrEmailActivity) { return EVENT_OPERATORS; } @@ -231,23 +258,35 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value), ); - }, [fieldType, isEventOrEmailActivity]); + }, [fieldType, isEventOrEmailActivity, isSegment]); const handleFieldChange = useCallback( (value: string) => { const selectedField = availableFields.find(f => f.value === value); const newFieldType = selectedField?.type || 'string'; const isEvent = newFieldType === 'event' || newFieldType === 'email'; + const isNewSegment = newFieldType === 'segment'; const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin'].includes( filter.operator, ); + const currentOperatorIsSegment = ['memberOfSegment', 'notMemberOfSegment'].includes(filter.operator); // Determine default operator and value based on new field type let newOperator = filter.operator; let newValue: string | number | boolean | 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 newOperator = 'triggered'; newValue = undefined; @@ -383,9 +422,11 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available {field.type} - {field.value !== field.label && ( + {field.description ? ( + {field.description} + ) : field.value !== field.label ? ( {field.value} - )} + ) : null} ))} @@ -407,8 +448,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available const oldOperator = filter.operator; // 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 noValueOperators = ['exists', 'notExists', 'triggered', 'notTriggered', 'memberOfSegment', 'notMemberOfSegment']; + const oldNeedsValue = !noValueOperators.includes(oldOperator); + const newNeedsValue = !noValueOperators.includes(newOperator); const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes( oldOperator, ); @@ -761,10 +803,11 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel interface SegmentFilterBuilderProps { condition: FilterCondition; onChange: (condition: FilterCondition) => void; + currentSegmentId?: string; } -export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) { - const {fields, loading} = useAvailableOptions(); +export function SegmentFilterBuilder({condition, onChange, currentSegmentId}: SegmentFilterBuilderProps) { + const {fields, loading} = useAvailableOptions(currentSegmentId); if (loading) { return
Loading available fields and events...
; diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx index 0f6f754..c315447 100644 --- a/apps/web/src/pages/segments/[id].tsx +++ b/apps/web/src/pages/segments/[id].tsx @@ -297,7 +297,7 @@ export default function SegmentDetailPage() { Build complex audience filters with AND/OR logic - + )} diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 68c4c5a..f9ec863 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -124,6 +124,8 @@ const segmentFilterSchema = z.object({ 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin', + 'memberOfSegment', + 'notMemberOfSegment', ]), 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 18ca3a0..f428a3e 100644 --- a/packages/types/src/segments/index.ts +++ b/packages/types/src/segments/index.ts @@ -24,7 +24,10 @@ export type SegmentFilterOperator = | 'triggeredWithin' // Event/email activity occurred within timeframe | 'triggeredOlderThan' // Event/email activity occurred more than X time ago | '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';