import { AudienceCriteria } from '../types' /** * Construtor de queries para diferentes tipos de critérios */ export class QueryBuilder { /** * Constrói filtros Supabase baseado nos critérios */ static buildFilters(query: any, criteria: AudienceCriteria, organizationId: string, projectId: string): any { query = query .eq('organization_id', organizationId) .eq('project_id', projectId) return this.applyAudienceCriteria(query, criteria) } /** * Aplica critérios de audiência ao query */ private static applyAudienceCriteria(query: any, criteria: AudienceCriteria): any { if (criteria.groups && Array.isArray(criteria.groups)) { return this.applyGroupsCriteria(query, criteria.groups) } if (criteria.filters && Array.isArray(criteria.filters)) { return this.applyFiltersCriteria(query, criteria.filters) } if (criteria.conditions && Array.isArray(criteria.conditions)) { return this.applyConditionsCriteria(query, criteria.conditions) } return query } /** * Aplica filtros no formato 'groups' (v2) */ private static applyGroupsCriteria(query: any, groups: any[]): any { const orConditions: string[] = [] for (const group of groups) { if (!group.rules || !Array.isArray(group.rules)) continue const groupConditions: string[] = [] for (const rule of group.rules) { const condition = this.buildConditionString(rule) if (condition) { groupConditions.push(condition) } } if (groupConditions.length > 0) { orConditions.push(groupConditions.join(',')) } } if (orConditions.length > 0) { query = query.or(orConditions.join(',')) } return query } /** * Aplica filtros no formato 'filters' (v1) */ private static applyFiltersCriteria(query: any, filters: any[]): any { const orGroups = filters.filter(f => f.operator === 'OR') const andGroups = filters.filter(f => f.operator !== 'OR') if (orGroups.length > 0) { const orConditions: string[] = [] for (const filterGroup of orGroups) { const groupConditions: string[] = [] for (const condition of filterGroup.conditions) { const condStr = this.buildConditionString(condition) if (condStr) { groupConditions.push(condStr) } } if (groupConditions.length > 0) { orConditions.push(groupConditions.join(',')) } } if (orConditions.length > 0) { query = query.or(orConditions.join(',')) } } for (const filterGroup of andGroups) { for (const condition of filterGroup.conditions) { query = this.applyCondition(query, condition) } } return query } /** * Aplica critérios no formato 'conditions' (normalizado) */ private static applyConditionsCriteria(query: any, conditions: any[]): any { for (const group of conditions) { if (group.operator === 'OR') { const orConditions = group.conditions.map((c: any) => this.buildConditionString(c)).filter(Boolean) if (orConditions.length > 0) { query = query.or(orConditions.join(',')) } } else { for (const condition of group.conditions) { query = this.applyCondition(query, condition) } } } return query } /** * Aplica uma condição individual ao query */ private static applyCondition(query: any, condition: any): any { const { field, operator, value } = condition const dbField = this.mapFieldToColumn(field, condition.customFieldKey) if (dbField === 'tags') { return this.applyTagCondition(query, operator, value) } switch (operator) { case 'equals': case 'eq': return query.eq(dbField, value) case 'not_equals': case 'neq': return query.neq(dbField, value) case 'contains': case 'ilike': return query.ilike(dbField, `%${value}%`) case 'not_contains': return query.not.ilike(dbField, `%${value}%`) case 'starts_with': return query.ilike(dbField, `${value}%`) case 'ends_with': return query.ilike(dbField, `%${value}`) case 'greater_than': case 'gt': return query.gt(dbField, value) case 'less_than': case 'lt': return query.lt(dbField, value) case 'is_empty': return query.or(`${dbField}.is.null,${dbField}.eq.`) case 'is_not_empty': return query.not(dbField, 'is', null).neq(dbField, '') default: console.warn(`⚠️ Operador desconhecido: ${operator}`) return query } } private static applyTagCondition(query: any, operator: string, value: any): any { const op = String(operator || '').trim().toLowerCase() const values = QueryBuilder.normalizeTagValues(value) if (op === 'has_all' || op === 'equals' || op === 'eq' || op === '=') { if (values.length === 0) return query.eq('id', '__never__') return query.contains('tags', values) } if (op === 'has_any' || op === 'contains' || op === 'in') { if (values.length === 0) return query.eq('id', '__never__') return query.overlaps('tags', values) } if (op === 'is_empty') { return query.or('tags.is.null,tags.eq.{}') } if (op === 'is_not_empty') { return query.not('tags', 'is', null).neq('tags', '{}') } console.warn(`⚠️ Operador de tag desconhecido: ${operator}`) return query.eq('id', '__never__') } private static normalizeTagValues(value: any): string[] { if (value === null || value === undefined) return [] const raw = Array.isArray(value) ? value : String(value).split(',') const out: string[] = [] for (const item of raw) { const v = String(item ?? '').trim() if (v.length > 0) out.push(v) } return out } /** * Constrói string de condição para uso em .or() */ private static buildConditionString(condition: any): string | null { const { field, operator, value } = condition if (!field || !operator) return null const dbField = this.mapFieldToColumn(field, condition.customFieldKey) if (dbField === 'tags') { return QueryBuilder.buildTagConditionString(operator, value) } switch (operator) { case 'equals': case 'eq': return `${dbField}.eq.${value}` case 'not_equals': case 'neq': return `${dbField}.neq.${value}` case 'contains': case 'ilike': return `${dbField}.ilike.*${value}*` case 'not_contains': return `${dbField}.not.ilike.*${value}*` case 'starts_with': return `${dbField}.ilike.${value}*` case 'ends_with': return `${dbField}.ilike.*${value}` case 'greater_than': case 'gt': return `${dbField}.gt.${value}` case 'less_than': case 'lt': return `${dbField}.lt.${value}` case 'is_empty': return `or(${dbField}.is.null,${dbField}.eq.)` case 'is_not_empty': return `and(${dbField}.not.is.null,${dbField}.neq.)` default: return null } } private static buildTagConditionString(operator: string, value: any): string | null { const op = String(operator || '').trim().toLowerCase() const values = QueryBuilder.normalizeTagValues(value) const arrLiteral = `{${values.map((v) => v.includes(',') ? `"${v.replace(/"/g, '\\"')}"` : v).join(',')}}` if (op === 'has_all' || op === 'equals' || op === 'eq' || op === '=') { if (values.length === 0) return null return `tags.cs.${arrLiteral}` } if (op === 'has_any' || op === 'contains' || op === 'in') { if (values.length === 0) return null return `tags.ov.${arrLiteral}` } if (op === 'is_empty') return `tags.is.null` if (op === 'is_not_empty') return `tags.not.is.null` return null } /** * Mapeia campo da audiência para coluna do banco */ private static mapFieldToColumn(field: string, customFieldKey?: string): string { if (field === 'custom_field' && customFieldKey) { return `properties->>${customFieldKey}` } const fieldMap: Record = { email: 'email', name: 'name', phone: 'phone', city: 'city', state: 'state', country: 'country', created_at: 'created_at', updated_at: 'updated_at', opted_in: 'opted_in', is_subscribed: 'is_subscribed' } return fieldMap[field] || field } }