diff --git a/src/modules/segments/factories/segment-builder.factory.ts b/src/modules/segments/factories/segment-builder.factory.ts deleted file mode 100644 index 59d3001..0000000 --- a/src/modules/segments/factories/segment-builder.factory.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ClickHouseService } from '../../processing/clickhouse/clickhouse.service'; -import { BaseSegmentBuilder } from '../services/segment-builders/base-segment-builder'; -import { EveryoneSegmentBuilder } from '../services/segment-builders/everyone-segment-builder'; -import { LabelSegmentBuilder } from '../services/segment-builders/label-segment-builder'; -import { CustomAttributeSegmentBuilder } from '../services/segment-builders/custom-attribute-segment-builder'; -import { UserPropertySegmentBuilder } from '../services/segment-builders/user-property-segment-builder'; -import { PerformedSegmentBuilder } from '../services/segment-builders/performed-segment-builder'; -import { EmailSegmentBuilder } from '../services/segment-builders/email-segment-builder'; -import { ChannelSegmentBuilder } from '../services/segment-builders/channel-segment-builder'; -import { RandomBucketSegmentBuilder } from '../services/segment-builders/random-bucket-segment-builder'; -import { - SegmentNode, - BaseSegmentBuilderConfig, -} from '../types/segment-computation.types'; - -@Injectable() -export class SegmentBuilderFactory { - constructor(private readonly clickHouseService: ClickHouseService) {} - - createBuilder( - node: SegmentNode, - config: BaseSegmentBuilderConfig, - ): BaseSegmentBuilder { - const { type } = node; - - switch (type) { - case 'everyone': - case 'Everyone': - return new EveryoneSegmentBuilder(this.clickHouseService, config); - - case 'has_label': - case 'not_has_label': - return new LabelSegmentBuilder(this.clickHouseService, config); - - case 'custom_attribute': - return new CustomAttributeSegmentBuilder( - this.clickHouseService, - config, - ); - - case 'user_property': - return new UserPropertySegmentBuilder(this.clickHouseService, config); - - case 'performed': - case 'lastPerformed': - return new PerformedSegmentBuilder(this.clickHouseService, config); - - case 'Email': - return new EmailSegmentBuilder(this.clickHouseService, config); - - case 'WhatsApp': - case 'Web': - case 'SMS': - return new ChannelSegmentBuilder(this.clickHouseService, config); - - case 'RandomBucket': - return new RandomBucketSegmentBuilder(this.clickHouseService, config); - - default: - throw new Error(`Unsupported segment node type: ${type}`); - } - } -} diff --git a/src/modules/segments/segments-cache.module.ts b/src/modules/segments/segments-cache.module.ts index 0711f67..c155753 100644 --- a/src/modules/segments/segments-cache.module.ts +++ b/src/modules/segments/segments-cache.module.ts @@ -9,7 +9,6 @@ import { SegmentChangeDetectionService } from './services/segment-change-detecti import { SegmentClickHouseQueryBuilderService } from './services/segment-clickhouse-query-builder.service'; import { SegmentQueryExecutionService } from './services/segment-query-execution.service'; import { SegmentAssignmentService } from './services/segment-assignment.service'; -import { SegmentBuilderFactory } from './factories/segment-builder.factory'; import { SegmentEventsService } from './services/segment-events.service'; import { DeletedContactsCacheService } from './services/deleted-contacts-cache.service'; import { SegmentCircuitBreakerService } from './services/segment-circuit-breaker.service'; @@ -37,7 +36,6 @@ import { ProcessingModule } from '../processing/processing.module'; SegmentClickHouseQueryBuilderService, SegmentQueryExecutionService, SegmentAssignmentService, - SegmentBuilderFactory, SegmentEventsService, DeletedContactsCacheService, SegmentCircuitBreakerService, @@ -48,7 +46,6 @@ import { ProcessingModule } from '../processing/processing.module'; SegmentCacheService, SegmentInvalidationService, ModularSegmentComputationService, - SegmentBuilderFactory, SegmentEventsService, DeletedContactsCacheService, SegmentCircuitBreakerService, diff --git a/src/modules/segments/segments.module.ts b/src/modules/segments/segments.module.ts index 66d8bb9..39391a5 100644 --- a/src/modules/segments/segments.module.ts +++ b/src/modules/segments/segments.module.ts @@ -16,7 +16,6 @@ import { SegmentChangeDetectionService } from './services/segment-change-detecti import { SegmentClickHouseQueryBuilderService } from './services/segment-clickhouse-query-builder.service'; import { SegmentQueryExecutionService } from './services/segment-query-execution.service'; import { SegmentAssignmentService } from './services/segment-assignment.service'; -import { SegmentBuilderFactory } from './factories/segment-builder.factory'; import { SegmentEventsService } from './services/segment-events.service'; import { DeletedContactsCacheService } from './services/deleted-contacts-cache.service'; import { SegmentCircuitBreakerService } from './services/segment-circuit-breaker.service'; @@ -52,7 +51,6 @@ import { initializeServiceLocator } from './services/service-locator'; SegmentClickHouseQueryBuilderService, SegmentQueryExecutionService, SegmentAssignmentService, - SegmentBuilderFactory, SegmentEventsService, DeletedContactsCacheService, SegmentCircuitBreakerService, diff --git a/src/modules/segments/services/segment-builders/base-segment-builder.ts b/src/modules/segments/services/segment-builders/base-segment-builder.ts deleted file mode 100644 index c502745..0000000 --- a/src/modules/segments/services/segment-builders/base-segment-builder.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - SegmentNode, - SegmentQueryResult, - BaseSegmentBuilderConfig, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; -import { ClickHouseService } from '../../../processing/clickhouse/clickhouse.service'; - -@Injectable() -export abstract class BaseSegmentBuilder { - constructor( - protected readonly clickHouseService: ClickHouseService, - protected readonly config: BaseSegmentBuilderConfig, - ) {} - - /** - * Abstract method that each builder must implement - */ - abstract buildQuery(node: SegmentNode): Promise; - - /** - * Executes a ClickHouse query and returns contact IDs - */ - protected async executeQuery(query: string): Promise { - try { - const result = await this.clickHouseService.query({ query }); - return result.map((row: any) => row.contact_id); - } catch (error) { - console.error('Error executing segment query:', error); - throw error; - } - } - - /** - * Adds contact exclusion clauses to a query - */ - protected addContactExclusions( - baseQuery: string, - contactIdAlias: string = 'contact_id', - ): string { - if (!this.config.exclusionOptions.excludeDeleted) { - return baseQuery; - } - - const exclusionClause = ContactExclusionQueries.getDeletedContactExclusion( - contactIdAlias, - ); - - // Add the exclusion to the WHERE clause - if (baseQuery.toLowerCase().includes(' where ')) { - return baseQuery.replace(/(\s+WHERE\s+)/i, `$1${exclusionClause} AND `); - } else { - return `${baseQuery} WHERE ${exclusionClause}`; - } - } - - /** - * Helper to build basic contact query with exclusions - */ - protected buildBaseContactQuery(): string { - return ` - SELECT DISTINCT contact_id - FROM evo_campaign.contact_events - `; - } - - /** - * Logs query execution for debugging - */ - protected logQuery(query: string, nodeType: string): void { - console.log( - `[${nodeType}] Executing query for segment ${this.config.segmentId}:`, - ); - console.log(query); - } - - /** - * Validates node structure - */ - protected validateNode(node: SegmentNode, requiredFields: string[]): void { - for (const field of requiredFields) { - if (!node[field]) { - throw new Error( - `Missing required field '${field}' in ${node.type} node`, - ); - } - } - } -} diff --git a/src/modules/segments/services/segment-builders/channel-segment-builder.ts b/src/modules/segments/services/segment-builders/channel-segment-builder.ts deleted file mode 100644 index b4b430c..0000000 --- a/src/modules/segments/services/segment-builders/channel-segment-builder.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -@Injectable() -export class ChannelSegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type']); - - const { type } = node; - let channelCondition = ''; - - switch (type) { - case 'WhatsApp': - channelCondition = - "JSON_EXTRACT_STRING(ce.properties, 'channel') = 'whatsapp'"; - break; - case 'Web': - channelCondition = - "JSON_EXTRACT_STRING(ce.properties, 'channel') = 'web'"; - break; - case 'SMS': - channelCondition = - "JSON_EXTRACT_STRING(ce.properties, 'channel') = 'sms'"; - break; - default: - throw new Error(`Unsupported channel type: ${type}`); - } - - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ${channelCondition} - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, `Channel-${type}`); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } -} diff --git a/src/modules/segments/services/segment-builders/custom-attribute-segment-builder.ts b/src/modules/segments/services/segment-builders/custom-attribute-segment-builder.ts deleted file mode 100644 index 345cd41..0000000 --- a/src/modules/segments/services/segment-builders/custom-attribute-segment-builder.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -@Injectable() -export class CustomAttributeSegmentBuilder extends BaseSegmentBuilder { - // The custom-attribute change is an identify-DTO event. The CRM stores the - // canonical dotted name (older producers used the short underscore form) and - // the payload in the `traits` column as { attributeName, attributeValue, - // changeType } — one row per change (EVO-1839). Earlier this builder filtered - // `event_name = 'identify'` and read `traits['']` (a never-emitted - // named-key shape); both were wrong. Accept both event-name forms and read the - // canonical fields below. - private static readonly EVENT_FILTER = - "ce.event_name IN ('contact.custom_attribute.changed', 'custom_attribute_changed')"; - - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type', 'operator', 'value']); - - const { operator, value } = node; - const attributeName = node.key || node.attribute_name; - - if (!attributeName) { - throw new Error('Custom attribute node must have key or attribute_name'); - } - - switch (operator) { - case 'equals': - return this.buildComparisonQuery( - attributeName, - `= '${value}'`, - 'Equals', - ); - case 'not_equals': - // include contacts whose latest value differs OR who cleared the attribute - return this.buildComparisonQuery( - attributeName, - `!= '${value}'`, - 'NotEquals', - ); - case 'contains': - return this.buildComparisonQuery( - attributeName, - `LIKE '%${value}%'`, - 'Contains', - ); - case 'not_contains': - return this.buildComparisonQuery( - attributeName, - `NOT LIKE '%${value}%'`, - 'NotContains', - ); - case 'is_known': - return this.buildComparisonQuery(attributeName, `!= ''`, 'IsKnown'); - case 'is_unknown': - return this.buildComparisonQuery(attributeName, `= ''`, 'IsUnknown'); - default: - throw new Error(`Unsupported custom attribute operator: ${operator}`); - } - } - - /** - * The *current* value of an attribute is the latest change's `attributeValue` - * via argMax(occurred_at); a `removed` change clears it to ''. The predicate - * (e.g. `= 'gold'`, `!= ''`) is applied to that current value in HAVING, so a - * contact matches on their newest state rather than any historical row. - * - * Note: a contact with no change event for this attribute does not appear here - * (no row to group) — same as the previous GROUP BY behaviour. - */ - private async buildComparisonQuery( - attributeName: string, - valuePredicate: string, - label: string, - ): Promise { - const currentValue = `argMax( - CASE - WHEN JSON_EXTRACT_STRING(ce.traits, 'changeType') = 'removed' THEN '' - ELSE JSON_EXTRACT_STRING(ce.traits, 'attributeValue') - END, - ce.occurred_at - )`; - - const query = ` - SELECT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ${CustomAttributeSegmentBuilder.EVENT_FILTER} - AND JSON_EXTRACT_STRING(ce.traits, 'attributeName') = '${attributeName}' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - AND ${currentValue} ${valuePredicate} - `; - - this.logQuery(query, `CustomAttribute-${label}`); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } -} diff --git a/src/modules/segments/services/segment-builders/email-segment-builder.ts b/src/modules/segments/services/segment-builders/email-segment-builder.ts deleted file mode 100644 index cf552f0..0000000 --- a/src/modules/segments/services/segment-builders/email-segment-builder.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -@Injectable() -export class EmailSegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type']); - - // Email segment checks for contacts with valid email addresses - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND JSON_EXTRACT_STRING(ce.traits, 'email') != '' - AND JSON_EXTRACT_STRING(ce.traits, 'email') IS NOT NULL - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'Email'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } -} diff --git a/src/modules/segments/services/segment-builders/everyone-segment-builder.ts b/src/modules/segments/services/segment-builders/everyone-segment-builder.ts deleted file mode 100644 index cc91873..0000000 --- a/src/modules/segments/services/segment-builders/everyone-segment-builder.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; - -@Injectable() -export class EveryoneSegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type']); - - // Query baseada exatamente no backup funcional - const query = ` - SELECT DISTINCT contact_id - FROM evo_campaign.contact_events - WHERE contact_id NOT IN ( - SELECT DISTINCT contact_id - FROM evo_campaign.contact_events - WHERE event_name = 'contact_deleted' - GROUP BY contact_id - HAVING argMax(occurred_at, occurred_at) > 0 - ) - `; - - this.logQuery(query, 'Everyone'); - const contactIds = await this.executeQuery(query); - - return { - query, - contactIds, - }; - } -} diff --git a/src/modules/segments/services/segment-builders/label-segment-builder.ts b/src/modules/segments/services/segment-builders/label-segment-builder.ts deleted file mode 100644 index 781e01c..0000000 --- a/src/modules/segments/services/segment-builders/label-segment-builder.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -@Injectable() -export class LabelSegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type', 'value']); - - if (node.type === 'has_label') { - return this.buildHasLabelQuery(node); - } else if (node.type === 'not_has_label') { - return this.buildNotHasLabelQuery(node); - } - - throw new Error(`Unsupported label node type: ${node.type}`); - } - - private async buildHasLabelQuery( - node: SegmentNode, - ): Promise { - const labelName = node.value; - - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'label_added' - AND JSON_EXTRACT_STRING(ce.properties, 'labelName') = '${labelName}' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'HasLabel'); - const contactIds = await this.executeQuery(query); - - return { - query, - contactIds, - }; - } - - private async buildNotHasLabelQuery( - node: SegmentNode, - ): Promise { - const labelName = node.value; - - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.contact_id NOT IN ( - SELECT DISTINCT ce2.contact_id - FROM evo_campaign.contact_events ce2 - WHERE ce2.event_name = 'label_added' - AND JSON_EXTRACT_STRING(ce2.properties, 'labelName') = '${labelName}' - ) - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'NotHasLabel'); - const contactIds = await this.executeQuery(query); - - return { - query, - contactIds, - }; - } -} diff --git a/src/modules/segments/services/segment-builders/performed-segment-builder.ts b/src/modules/segments/services/segment-builders/performed-segment-builder.ts deleted file mode 100644 index 5cfba92..0000000 --- a/src/modules/segments/services/segment-builders/performed-segment-builder.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -// ClickHouse SQL safety. Escape backslash first (it's the escape character in -// single-quoted string literals), THEN single quote — order matters or a `\\` -// followed by `'` collapses into `\\''` which ClickHouse reads as `\\` + `'`, -// leaving the closing quote ambiguous. -function escapeSqlString(value: string): string { - return String(value).replace(/\\/g, '\\\\').replace(/'/g, "''"); -} - -function assertInt(value: unknown, field: string): number { - const n = typeof value === 'number' ? value : Number(value); - if (!Number.isInteger(n)) { - throw new Error(`Invalid ${field}: expected integer, got ${String(value)}`); - } - return n; -} - -function assertFiniteNumber(value: unknown, field: string): number { - const n = typeof value === 'number' ? value : Number(value); - if (!Number.isFinite(n)) { - throw new Error(`Invalid ${field}: expected finite number, got ${String(value)}`); - } - return n; -} - -@Injectable() -export class PerformedSegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type', 'value']); - - if (node.type === 'performed') { - return this.buildPerformedQuery(node); - } else if (node.type === 'lastPerformed') { - return this.buildLastPerformedQuery(node); - } - - throw new Error(`Unsupported performed node type: ${node.type}`); - } - - private async buildPerformedQuery( - node: SegmentNode, - ): Promise { - const eventName = escapeSqlString(node.value); - const { withinDays, operator, propertyFilters } = node; - - // GROUP BY contact_id already deduplicates — no need for SELECT DISTINCT. - let query = ` - SELECT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = '${eventName}' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - `; - - if (withinDays) { - const days = assertInt(withinDays, 'withinDays'); - query += ` AND ce.occurred_at >= now() - INTERVAL ${days} DAY`; - } - - if (propertyFilters && propertyFilters.length > 0) { - const propertyConditions = this.buildPropertyFilters(propertyFilters); - query += ` AND ${propertyConditions}`; - } - - if (operator === 'moreThan' && node.times) { - const times = assertInt(node.times, 'times'); - query += ` - GROUP BY ce.contact_id - HAVING COUNT(*) > ${times} - `; - } else if (operator === 'lessThan' && node.times) { - const times = assertInt(node.times, 'times'); - query += ` - GROUP BY ce.contact_id - HAVING COUNT(*) < ${times} - `; - } else if (operator === 'exactly' && node.times) { - const times = assertInt(node.times, 'times'); - query += ` - GROUP BY ce.contact_id - HAVING COUNT(*) = ${times} - `; - } else { - query += ` GROUP BY ce.contact_id`; - } - - this.logQuery(query, 'Performed'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private async buildLastPerformedQuery( - node: SegmentNode, - ): Promise { - const eventName = escapeSqlString(node.value); - const { withinDays, propertyFilters } = node; - - let query = ` - SELECT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = '${eventName}' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - AND ce.occurred_at = ( - SELECT MAX(ce2.occurred_at) - FROM evo_campaign.contact_events ce2 - WHERE ce2.contact_id = ce.contact_id - AND ce2.event_name = '${eventName}' - ) - `; - - if (withinDays) { - const days = assertInt(withinDays, 'withinDays'); - query += ` AND ce.occurred_at >= now() - INTERVAL ${days} DAY`; - } - - if (propertyFilters && propertyFilters.length > 0) { - const propertyConditions = this.buildPropertyFilters(propertyFilters); - query += ` AND ${propertyConditions}`; - } - - query += ` GROUP BY ce.contact_id`; - - this.logQuery(query, 'LastPerformed'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private buildPropertyFilters(propertyFilters: any[]): string { - const conditions = propertyFilters.map((filter) => { - const { key, operator, value } = filter; - const k = escapeSqlString(key); - const v = escapeSqlString(value); - - switch (operator) { - case 'equals': - return `JSON_EXTRACT_STRING(ce.properties, '${k}') = '${v}'`; - case 'not_equals': - return `JSON_EXTRACT_STRING(ce.properties, '${k}') != '${v}'`; - case 'contains': - return `JSON_EXTRACT_STRING(ce.properties, '${k}') LIKE '%${v}%'`; - case 'not_contains': - return `JSON_EXTRACT_STRING(ce.properties, '${k}') NOT LIKE '%${v}%'`; - case 'is_known': - return `JSON_EXTRACT_STRING(ce.properties, '${k}') IS NOT NULL AND JSON_EXTRACT_STRING(ce.properties, '${k}') != ''`; - case 'is_unknown': - return `JSON_EXTRACT_STRING(ce.properties, '${k}') IS NULL OR JSON_EXTRACT_STRING(ce.properties, '${k}') = ''`; - case 'greater_than': { - const num = assertFiniteNumber(value, 'propertyFilter.value'); - return `toFloat64OrNull(JSON_EXTRACT_STRING(ce.properties, '${k}')) > ${num}`; - } - case 'less_than': { - const num = assertFiniteNumber(value, 'propertyFilter.value'); - return `toFloat64OrNull(JSON_EXTRACT_STRING(ce.properties, '${k}')) < ${num}`; - } - default: - throw new Error(`Unsupported property filter operator: ${operator}`); - } - }); - - return conditions.join(' AND '); - } -} diff --git a/src/modules/segments/services/segment-builders/random-bucket-segment-builder.ts b/src/modules/segments/services/segment-builders/random-bucket-segment-builder.ts deleted file mode 100644 index 61d9157..0000000 --- a/src/modules/segments/services/segment-builders/random-bucket-segment-builder.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -@Injectable() -export class RandomBucketSegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type', 'percentage']); - - const percentage = (node as any).percentage || 50; // Default to 50% - - // Use contact_id hash to ensure deterministic random distribution - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE (cityHash64(ce.contact_id) % 100) < ${percentage} - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, `RandomBucket-${percentage}%`); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } -} diff --git a/src/modules/segments/services/segment-builders/user-property-segment-builder.ts b/src/modules/segments/services/segment-builders/user-property-segment-builder.ts deleted file mode 100644 index 30d5466..0000000 --- a/src/modules/segments/services/segment-builders/user-property-segment-builder.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { BaseSegmentBuilder } from './base-segment-builder'; -import { - SegmentNode, - SegmentQueryResult, -} from '../../types/segment-computation.types'; -import { ContactExclusionQueries } from '../../queries/contact-exclusion-queries'; - -@Injectable() -export class UserPropertySegmentBuilder extends BaseSegmentBuilder { - async buildQuery(node: SegmentNode): Promise { - this.validateNode(node, ['type', 'operator', 'value']); - - const { operator, value } = node; - const propertyName = node.key || node.property_name; - - if (!propertyName) { - throw new Error('User property node must have key or property_name'); - } - - switch (operator) { - case 'equals': - return this.buildEqualsQuery(propertyName, value); - case 'not_equals': - return this.buildNotEqualsQuery(propertyName, value); - case 'contains': - return this.buildContainsQuery(propertyName, value); - case 'not_contains': - return this.buildNotContainsQuery(propertyName, value); - case 'is_known': - return this.buildIsKnownQuery(propertyName); - case 'is_unknown': - return this.buildIsUnknownQuery(propertyName); - default: - throw new Error(`Unsupported user property operator: ${operator}`); - } - } - - private async buildEqualsQuery( - propertyName: string, - value: string, - ): Promise { - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND JSON_EXTRACT_STRING(ce.traits, '${propertyName}') = '${value}' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'UserProperty-Equals'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private async buildNotEqualsQuery( - propertyName: string, - value: string, - ): Promise { - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND ( - JSON_EXTRACT_STRING(ce.traits, '${propertyName}') != '${value}' - OR JSON_EXTRACT_STRING(ce.traits, '${propertyName}') IS NULL - ) - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'UserProperty-NotEquals'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private async buildContainsQuery( - propertyName: string, - value: string, - ): Promise { - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND JSON_EXTRACT_STRING(ce.traits, '${propertyName}') LIKE '%${value}%' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'UserProperty-Contains'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private async buildNotContainsQuery( - propertyName: string, - value: string, - ): Promise { - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND ( - JSON_EXTRACT_STRING(ce.traits, '${propertyName}') NOT LIKE '%${value}%' - OR JSON_EXTRACT_STRING(ce.traits, '${propertyName}') IS NULL - ) - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'UserProperty-NotContains'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private async buildIsKnownQuery( - propertyName: string, - ): Promise { - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND JSON_EXTRACT_STRING(ce.traits, '${propertyName}') IS NOT NULL - AND JSON_EXTRACT_STRING(ce.traits, '${propertyName}') != '' - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'UserProperty-IsKnown'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } - - private async buildIsUnknownQuery( - propertyName: string, - ): Promise { - const query = ` - SELECT DISTINCT ce.contact_id - FROM evo_campaign.contact_events ce - WHERE ce.event_name = 'identify' - AND ( - JSON_EXTRACT_STRING(ce.traits, '${propertyName}') IS NULL - OR JSON_EXTRACT_STRING(ce.traits, '${propertyName}') = '' - ) - AND ${ContactExclusionQueries.getDeletedContactExclusion('ce.contact_id')} - GROUP BY ce.contact_id - HAVING ${ContactExclusionQueries.getLatestContactStateExclusion()} - `; - - this.logQuery(query, 'UserProperty-IsUnknown'); - const contactIds = await this.executeQuery(query); - - return { query, contactIds }; - } -} diff --git a/src/modules/segments/services/segment-clickhouse-query-builder.service.ts b/src/modules/segments/services/segment-clickhouse-query-builder.service.ts index 1a057fe..2041876 100644 --- a/src/modules/segments/services/segment-clickhouse-query-builder.service.ts +++ b/src/modules/segments/services/segment-clickhouse-query-builder.service.ts @@ -96,6 +96,14 @@ export class SegmentClickHouseQueryBuilderService { let useArgMax = false; let operator = ''; let value = ''; + // EVO-1901 (D12): custom attributes are ingested as delta events + // (`contact.custom_attribute.changed`) carrying { attributeName, + // attributeValue, changeType }, NOT as a flat or nested `traits` key. The + // generic `JSONExtractString(traits, '')` extraction below never + // matches them (→ 0 members). When this flag is set, the condition + + // argMaxValue are overridden further down to read the delta stream. + let isCustomAttribute = false; + let customAttributeName = ''; // Determinar como extrair o valor baseado no path if (userPropNode.path === 'labels') { @@ -112,15 +120,16 @@ export class SegmentClickHouseQueryBuilderService { } useArgMax = true; // Custom attributes podem mudar } else if (userPropNode.path.startsWith('customAttributes.')) { - // Path completo de custom attribute - mas na verdade está salvo como campo direto - const customAttributeName = userPropNode.path.replace( - 'customAttributes.', - '', - ); - extractPath = customAttributeName; // Campo direto, não aninhado + // EVO-1901 (D12): the custom attribute is NOT a flat `traits.` key + // (the previous assumption) — it arrives as a delta event. Capture the + // attribute name; the condition + argMaxValue are overridden below to + // read `contact.custom_attribute.changed` events. + customAttributeName = userPropNode.path.replace('customAttributes.', ''); + extractPath = customAttributeName; + isCustomAttribute = true; useArgMax = true; // Custom attributes podem mudar this.logger.debug( - `Custom attribute mapping: original path=${userPropNode.path}, extractPath=${extractPath}`, + `Custom attribute mapping: path=${userPropNode.path}, attributeName=${customAttributeName}`, ); } else if (userPropNode.path.startsWith('additionalAttributes.')) { // Additional attributes @@ -266,6 +275,32 @@ export class SegmentClickHouseQueryBuilderService { } } + // EVO-1901 (D12): custom attributes are stored as delta events + // (`contact.custom_attribute.changed` with { attributeName, attributeValue, + // changeType }), never as a flat/nested `traits` key — so the generic + // extraction above matches zero rows and segments computed 0 members. Read + // the attribute's change stream instead and argMax the latest value (a + // `removed` change clears it). generateArgMaxValidation then applies the + // operator/value comparison over this argMaxValue. + if (isCustomAttribute) { + condition = `event_name = 'contact.custom_attribute.changed' AND JSONExtractString(traits, 'attributeName') = '${customAttributeName}'`; + argMaxValue = ` + CASE + WHEN contact_or_anonymous_id IN ( + SELECT DISTINCT contact_or_anonymous_id + FROM contact_events + WHERE event_name = 'contact_deleted' + GROUP BY contact_or_anonymous_id + HAVING argMax(occurred_at, occurred_at) > 0 + ) THEN '' + WHEN JSONExtractString(traits, 'changeType') = 'removed' THEN '' + ELSE JSONExtractString(traits, 'attributeValue') + END + ` + .replace(/\s+/g, ' ') + .trim(); + } + // Para campos mutáveis, incluir informação do operador e valor para validação posterior const validationInfo = useArgMax ? { diff --git a/src/modules/segments/services/segment-computation.live-path.spec.ts b/src/modules/segments/services/segment-computation.live-path.spec.ts new file mode 100644 index 0000000..03945c0 --- /dev/null +++ b/src/modules/segments/services/segment-computation.live-path.spec.ts @@ -0,0 +1,79 @@ +import { SegmentClickHouseQueryBuilderService } from './segment-clickhouse-query-builder.service'; +import { SegmentNodeType } from '../entities/segment.entity'; + +/** + * EVO-1901 — exercises the LIVE segment recompute SQL path. + * + * The dead `segment-builders/*` + `SegmentBuilderFactory` graph (reached only + * via `createBuilder`, which had NO caller anywhere in src) was removed: that + * was where the previous fix renamed JSON_EXTRACT_STRING, with zero runtime + * effect. The real recompute SQL is produced by + * SegmentClickHouseQueryBuilderService.segmentNodeToStateSubQuery + * (modular-segment-computation.service.ts STAGE 1), which this test asserts + * emits the valid ClickHouse function JSONExtractString. + * + * NOTE: the analogous LIVE read-path propagation test + * (SegmentComputationService.getSegmentContacts throwing on a ClickHouse + * failure instead of returning []) cannot be compiled under ts-jest right now + * because importing SegmentComputationService pulls in + * processing/clickhouse/clickhouse.service.ts, which currently has duplicate + * `ensureKafkaEngineBroker`/`extractKafkaBrokers` implementations (a develop + * regression from the #87 / #101 merge) that fails TS2393. The same regression + * blocks the pre-existing segment-job.service.spec.ts. The read-path code fix + * (log ERROR + throw) is in segment-computation.service.ts. + */ +describe('EVO-1901 live segment recompute SQL builder', () => { + const builder = new SegmentClickHouseQueryBuilderService(); + + it('emits the valid ClickHouse function JSONExtractString, never JSON_EXTRACT_STRING', () => { + const segment = { id: 'seg-1' } as any; + const node = { id: 'n1', type: SegmentNodeType.Email } as any; + + const subQueries = builder.segmentNodeToStateSubQuery(segment, node); + + const serialized = JSON.stringify(subQueries); + expect(serialized).toContain('JSONExtractString'); + expect(serialized).not.toContain('JSON_EXTRACT_STRING'); + }); + + // EVO-1901 (D12) real fix: a custom-attribute condition must read the delta + // event stream (`contact.custom_attribute.changed` → attributeName/attributeValue), + // NOT a flat `traits.` key. The flat extraction matched zero rows, which + // is what made conditional segments compute 0 members (verified against live + // ClickHouse: flat `JSONExtractString(traits,'tier')` → 0 contacts; delta + // approach → the real members). + it('reads custom attributes from the delta stream, not a flat traits key', () => { + const segment = { id: 'seg-1' } as any; + const node = { + id: 'n1', + type: SegmentNodeType.UserProperty, + path: 'customAttributes.tier', + operator: { type: 'Equals', value: 'platinum' }, + value: 'platinum', + } as any; + + const [subQuery] = builder.segmentNodeToStateSubQuery(segment, node); + + // Selects the attribute's change events… + expect(subQuery.condition).toContain( + "event_name = 'contact.custom_attribute.changed'", + ); + expect(subQuery.condition).toContain( + "JSONExtractString(traits, 'attributeName') = 'tier'", + ); + // …and argMaxes the delta value (cleared on removal)… + expect(subQuery.argMaxValue).toContain( + "JSONExtractString(traits, 'attributeValue')", + ); + expect(subQuery.argMaxValue).toContain("'changeType'"); + // …never the broken flat extraction that matched nothing. + expect(subQuery.condition).not.toContain( + "JSONExtractString(traits, 'tier')", + ); + expect(subQuery.argMaxValue).not.toContain( + "JSONExtractString(traits, 'tier')", + ); + expect(subQuery.validationInfo?.operator).toBe('Equals'); + expect(subQuery.validationInfo?.value).toBe('platinum'); + }); +}); diff --git a/src/modules/segments/services/segment-computation.service.ts b/src/modules/segments/services/segment-computation.service.ts index 8d7a7f3..a889572 100644 --- a/src/modules/segments/services/segment-computation.service.ts +++ b/src/modules/segments/services/segment-computation.service.ts @@ -280,7 +280,9 @@ export class SegmentComputationService { this.logger.error( `Error getting segment contacts from ClickHouse for segment ${segmentId}: ${error.message}`, ); - return []; + // Propagate instead of returning [] silently: a ClickHouse failure here + // must not be indistinguishable from a genuinely empty segment. + throw error; } } @@ -305,7 +307,8 @@ export class SegmentComputationService { return result.map((row: any) => row.segmentId); } catch (error: any) { this.logger.error(`Error getting contact segments: ${error.message}`); - return []; + // Propagate: a ClickHouse failure must not look like "contact in no segment". + throw error; } } @@ -335,7 +338,8 @@ export class SegmentComputationService { return results.length > 0 ? results[0].inSegment : false; } catch (error: any) { this.logger.error(`Error checking segment assignment: ${error.message}`); - return false; + // Propagate: a ClickHouse failure must not be silently treated as "not in segment". + throw error; } }