@reachy/audience-module 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gitlab-ci.yml ADDED
@@ -0,0 +1,138 @@
1
+ image: node:20
2
+
3
+ variables:
4
+ GIT_DEPTH: 0
5
+ GIT_STRATEGY: fetch
6
+
7
+ stages:
8
+ - test
9
+ - build
10
+ - tag
11
+ - publish
12
+ - notify
13
+
14
+ .slack-notify-template:
15
+ image: alpine:3.20
16
+ before_script:
17
+ - apk add --no-cache curl jq
18
+ - |
19
+ slack_post () {
20
+ [ -z "${SLACK_WEBHOOK_URL:-}" ] && { echo "SLACK_WEBHOOK_URL não configurada; pulando notificação."; return 0; }
21
+ local color="$1" title="$2" emoji="$3" msg="$4"
22
+ local short_sha="${CI_COMMIT_SHA:0:8}"
23
+ [ -f build_info.env ] && set -a && . build_info.env && set +a || true
24
+ payload="$(
25
+ jq -n \
26
+ --arg ch "${SLACK_CHANNEL:-}" \
27
+ --arg color "$color" --arg title "$title" --arg emoji "$emoji" \
28
+ --arg project "$CI_PROJECT_PATH" --arg branch "$CI_COMMIT_REF_NAME" \
29
+ --arg sha "$short_sha" --arg actor "${GITLAB_USER_NAME:-ci}" \
30
+ --arg job_url "$CI_JOB_URL" --arg pipe_url "$CI_PIPELINE_URL" \
31
+ --arg image "${NEW_IMAGE:-${IMAGE_REPO:-}<unknown>:${IMAGE_TAG:-}}" \
32
+ --arg service "${PORTAINER_SERVICE_NAME:-<desconhecido>}" \
33
+ --arg msg "$msg" '
34
+ def base:
35
+ { attachments: [ { color: $color, blocks: [
36
+ { "type":"header", "text": { "type":"plain_text", "text": ($emoji+" "+$title) } },
37
+ { "type":"section", "fields": [
38
+ { "type":"mrkdwn", "text": ("*Projeto:*\n"+$project) },
39
+ { "type":"mrkdwn", "text": ("*Branch:*\n"+$branch) },
40
+ { "type":"mrkdwn", "text": ("*Commit:*\n"+$sha) },
41
+ { "type":"mrkdwn", "text": ("*Autor:*\n"+$actor) },
42
+ { "type":"mrkdwn", "text": ("*Serviço:*\n"+$service) },
43
+ { "type":"mrkdwn", "text": ("*Imagem:*\n"+$image) }
44
+ ] },
45
+ { "type":"actions", "elements": [
46
+ { "type":"button", "text": { "type":"plain_text","text":"Ver Job" }, "url": $job_url },
47
+ { "type":"button", "text": { "type":"plain_text","text":"Ver Pipeline" }, "url": $pipe_url }
48
+ ] }
49
+ ] } ] };
50
+ def add_msg(obj):
51
+ if ($msg|length) > 0 then obj | .attachments[0].blocks += [ { "type":"context", "elements":[ { "type":"mrkdwn", "text": $msg } ] } ] else obj end;
52
+ base | (if ($ch|length) > 0 then . + {channel:$ch} else . end) | add_msg(.)
53
+ '
54
+ )"
55
+ curl -sS -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK_URL" >/dev/null || true
56
+ }
57
+
58
+ default:
59
+ cache:
60
+ key: ${CI_COMMIT_REF_SLUG}
61
+ paths:
62
+ - node_modules/
63
+ before_script:
64
+ - npm ci
65
+ tags:
66
+ - docker
67
+
68
+ lint_and_test:
69
+ stage: test
70
+ script:
71
+ - npm test -- --watch=false || echo "No tests configured"
72
+ rules:
73
+ - if: '$CI_COMMIT_BRANCH == "main"'
74
+
75
+ build:
76
+ stage: build
77
+ script:
78
+ - npm run build
79
+ artifacts:
80
+ paths:
81
+ - dist/
82
+ needs:
83
+ - lint_and_test
84
+ rules:
85
+ - if: '$CI_COMMIT_BRANCH == "main"'
86
+
87
+ create_tag:
88
+ stage: tag
89
+ needs:
90
+ - build
91
+ before_script: []
92
+ script:
93
+ - git config --global user.email "${GITLAB_USER_EMAIL:-ci@gitlab}"
94
+ - git config --global user.name "${GITLAB_USER_NAME:-gitlab-ci}"
95
+ - git fetch --all --tags
96
+ - VERSION=$(node -p "require('./package.json').version")
97
+ - TAG="v${VERSION}"
98
+ - if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag $TAG already exists, skipping"; exit 0; fi
99
+ - |
100
+ if [ -z "${GITLAB_TOKEN:-}" ]; then
101
+ echo "GITLAB_TOKEN não configurado com permissão write_repository; não é possível criar tag."
102
+ exit 1
103
+ fi
104
+ - |
105
+ AUTH_USER="oauth2"
106
+ AUTH_TOKEN="$GITLAB_TOKEN"
107
+ REPO_URL="https://${AUTH_USER}:${AUTH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
108
+ git tag "$TAG"
109
+ - git push "$REPO_URL" "$TAG"
110
+ rules:
111
+ - if: '$CI_COMMIT_BRANCH == "main"'
112
+
113
+ publish_npm:
114
+ stage: publish
115
+ needs:
116
+ - job: build
117
+ artifacts: true
118
+ - create_tag
119
+ script:
120
+ - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
121
+ - npm publish --access public
122
+ rules:
123
+ - if: '$CI_COMMIT_BRANCH == "main"'
124
+ when: manual
125
+ allow_failure: false
126
+
127
+ notify:publish:
128
+ stage: notify
129
+ extends: [.slack-notify-template]
130
+ needs:
131
+ - job: publish_npm
132
+ rules:
133
+ - if: '$CI_COMMIT_BRANCH == "main"'
134
+ when: on_success
135
+ - when: never
136
+ script:
137
+ - VERSION=$(jq -r '.version' package.json)
138
+ - slack_post "#2EB67D" "Publicação npm concluída" ":white_check_mark:" "Pacote publicado a partir do branch main. Versão \`${VERSION}\`."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reachy/audience-module",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Módulo reutilizável para consultas e criação de audiences",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,353 @@
1
+ import { StaticAudienceExecutor } from './executors/StaticAudienceExecutor'
2
+ import { CriteriaParser } from './builders/CriteriaParser'
3
+ import {
4
+ AudienceCriteria,
5
+ AudienceQueryOptions,
6
+ AudienceExecutionResult,
7
+ AudienceBuilderConfig,
8
+ CreateAudienceData,
9
+ UpdateAudienceData
10
+ } from './types'
11
+
12
+ /**
13
+ * Módulo centralizado para todas as operações de Audience
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const audienceModule = new AudienceModule()
18
+ *
19
+ * // Configurar repositórios necessários
20
+ * audienceModule.setRepositories({
21
+ * contactRepository,
22
+ * audienceRepository,
23
+ * memberRepository
24
+ * })
25
+ *
26
+ * // Executar query de audiência
27
+ * const result = await audienceModule.executeQuery(criteria, {
28
+ * organizationId: 'org-123',
29
+ * projectId: 'proj-456'
30
+ * })
31
+ * ```
32
+ */
33
+ export class AudienceModule {
34
+ private audienceRepository: any
35
+ private memberRepository: any
36
+ private contactRepository: any
37
+ private staticExecutor: StaticAudienceExecutor
38
+
39
+ constructor(config: AudienceBuilderConfig = {}) {
40
+ this.staticExecutor = new StaticAudienceExecutor()
41
+
42
+ // Log config se debug estiver habilitado
43
+ if (config.enableDebugLogs) {
44
+ console.log('🔧 AudienceModule configurado:', config)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Configura os repositórios necessários
50
+ * Deve ser chamado antes de usar o módulo
51
+ */
52
+ setRepositories(repositories: {
53
+ contactRepository?: any
54
+ audienceRepository?: any
55
+ memberRepository?: any
56
+ }) {
57
+ if (repositories.contactRepository) {
58
+ this.contactRepository = repositories.contactRepository
59
+ this.staticExecutor.setContactRepository(repositories.contactRepository)
60
+ }
61
+ if (repositories.audienceRepository) {
62
+ this.audienceRepository = repositories.audienceRepository
63
+ }
64
+ if (repositories.memberRepository) {
65
+ this.memberRepository = repositories.memberRepository
66
+ }
67
+ }
68
+
69
+ // ===========================
70
+ // QUERY EXECUTION
71
+ // ===========================
72
+
73
+ /**
74
+ * Executa query de audiência e retorna resultados
75
+ */
76
+ async executeQuery(
77
+ criteria: string | AudienceCriteria,
78
+ options: AudienceQueryOptions
79
+ ): Promise<AudienceExecutionResult> {
80
+ const parsed = CriteriaParser.parse(criteria)
81
+ const type = CriteriaParser.getAudienceType(parsed)
82
+
83
+ if (type === 'static') {
84
+ return this.staticExecutor.execute(parsed, options)
85
+ } else {
86
+ return this.executeLiveQuery(parsed, options)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Retorna apenas a contagem de contatos (mais rápido)
92
+ */
93
+ async getContactCount(
94
+ criteria: string | AudienceCriteria,
95
+ options: Omit<AudienceQueryOptions, 'pagination'>
96
+ ): Promise<number> {
97
+ const parsed = CriteriaParser.parse(criteria)
98
+ return this.staticExecutor.executeCount(parsed, options)
99
+ }
100
+
101
+ /**
102
+ * Retorna IDs de contatos que atendem aos critérios
103
+ */
104
+ async getContactIds(
105
+ criteria: string | AudienceCriteria,
106
+ organizationId: string,
107
+ projectId: string
108
+ ): Promise<Set<string>> {
109
+ if (!this.contactRepository) {
110
+ throw new Error('ContactRepository não configurado')
111
+ }
112
+
113
+ const parsed = CriteriaParser.parse(criteria)
114
+ return this.contactRepository.getContactIdsByAudienceCriteriaV2(
115
+ organizationId,
116
+ projectId,
117
+ parsed
118
+ )
119
+ }
120
+
121
+ // ===========================
122
+ // CRUD OPERATIONS
123
+ // ===========================
124
+
125
+ /**
126
+ * Cria nova audiência
127
+ */
128
+ async createAudience(data: CreateAudienceData) {
129
+ if (!this.audienceRepository) {
130
+ throw new Error('AudienceRepository não configurado')
131
+ }
132
+
133
+ const validation = CriteriaParser.validate(data.criteria)
134
+ if (!validation.valid) {
135
+ throw new Error(`Critérios inválidos: ${validation.errors.join(', ')}`)
136
+ }
137
+
138
+ const { data: audience, error } = await this.audienceRepository.create(data)
139
+ if (error) throw error
140
+
141
+ const type = CriteriaParser.getAudienceType(data.criteria)
142
+ if (type === 'static') {
143
+ const count = await this.getContactCount(data.criteria, {
144
+ organizationId: data.organization_id,
145
+ projectId: data.project_id
146
+ })
147
+
148
+ await this.audienceRepository.updateCount(audience!.id, count)
149
+ audience!.count = count
150
+ }
151
+
152
+ return { data: audience, error: null }
153
+ }
154
+
155
+ /**
156
+ * Atualiza audiência existente
157
+ */
158
+ async updateAudience(
159
+ id: string,
160
+ updateData: UpdateAudienceData,
161
+ organizationId: string,
162
+ projectId: string
163
+ ) {
164
+ if (!this.audienceRepository) {
165
+ throw new Error('AudienceRepository não configurado')
166
+ }
167
+
168
+ if (updateData.criteria) {
169
+ const validation = CriteriaParser.validate(updateData.criteria)
170
+ if (!validation.valid) {
171
+ throw new Error(`Critérios inválidos: ${validation.errors.join(', ')}`)
172
+ }
173
+ }
174
+
175
+ const { data: audience, error } = await this.audienceRepository.update(id, updateData)
176
+ if (error) throw error
177
+
178
+ if (updateData.criteria) {
179
+ const type = CriteriaParser.getAudienceType(updateData.criteria)
180
+ if (type === 'static') {
181
+ const count = await this.getContactCount(updateData.criteria, {
182
+ organizationId,
183
+ projectId
184
+ })
185
+
186
+ await this.audienceRepository.updateCount(id, count)
187
+ audience!.count = count
188
+ }
189
+ }
190
+
191
+ return { data: audience, error: null }
192
+ }
193
+
194
+ /**
195
+ * Busca audiência por ID
196
+ */
197
+ async getAudienceById(id: string, organizationId: string, projectId: string) {
198
+ if (!this.audienceRepository) {
199
+ throw new Error('AudienceRepository não configurado')
200
+ }
201
+ return this.audienceRepository.findById(id, organizationId, projectId)
202
+ }
203
+
204
+ /**
205
+ * Deleta audiência
206
+ */
207
+ async deleteAudience(id: string) {
208
+ if (!this.audienceRepository) {
209
+ throw new Error('AudienceRepository não configurado')
210
+ }
211
+ return this.audienceRepository.delete(id)
212
+ }
213
+
214
+ // ===========================
215
+ // MEMBERS MANAGEMENT
216
+ // ===========================
217
+
218
+ /**
219
+ * Verifica se uma audience tem regra de evento específico
220
+ */
221
+ hasEventRule(criteria: any, eventName: string): boolean {
222
+ try {
223
+ // Parse do critério
224
+ let parsed = criteria
225
+ if (typeof criteria === 'string') {
226
+ try {
227
+ parsed = JSON.parse(criteria)
228
+ } catch {
229
+ return false
230
+ }
231
+ }
232
+
233
+ // Verificar formato V2 com groups/rules
234
+ if (parsed.groups && Array.isArray(parsed.groups)) {
235
+ for (const group of parsed.groups) {
236
+ if (group.rules && Array.isArray(group.rules)) {
237
+ const found = group.rules.some((rule: any) =>
238
+ rule.kind === 'event' && rule.eventName === eventName
239
+ )
240
+ if (found) return true
241
+ }
242
+ }
243
+ }
244
+
245
+ return false
246
+ } catch (error) {
247
+ console.error('Error checking event rule:', error)
248
+ return false
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Adiciona contatos à audiência (bulk)
254
+ */
255
+ async addMembers(
256
+ audienceId: string,
257
+ contactIds: string[],
258
+ organizationId: string,
259
+ projectId: string,
260
+ origin: 'realtime' | 'backfill' = 'backfill'
261
+ ) {
262
+ if (!this.memberRepository) {
263
+ throw new Error('MemberRepository não configurado')
264
+ }
265
+
266
+ return this.memberRepository.bulkUpsert(
267
+ audienceId,
268
+ organizationId,
269
+ projectId,
270
+ contactIds,
271
+ origin
272
+ )
273
+ }
274
+
275
+ /**
276
+ * Lista membros da audiência
277
+ */
278
+ async getMembers(
279
+ audienceId: string,
280
+ organizationId: string,
281
+ projectId: string,
282
+ pagination?: { page?: number; limit?: number }
283
+ ) {
284
+ if (!this.memberRepository) {
285
+ throw new Error('MemberRepository não configurado')
286
+ }
287
+
288
+ return this.memberRepository.listMembers(
289
+ audienceId,
290
+ organizationId,
291
+ projectId,
292
+ pagination
293
+ )
294
+ }
295
+
296
+ // ===========================
297
+ // UTILITIES
298
+ // ===========================
299
+
300
+ /**
301
+ * Valida critérios de audiência
302
+ */
303
+ validateCriteria(criteria: string | AudienceCriteria) {
304
+ const parsed = CriteriaParser.parse(criteria)
305
+ return CriteriaParser.validate(parsed)
306
+ }
307
+
308
+ /**
309
+ * Detecta tipo de audiência
310
+ */
311
+ getAudienceType(criteria: string | AudienceCriteria): 'static' | 'live' {
312
+ const parsed = CriteriaParser.parse(criteria)
313
+ return CriteriaParser.getAudienceType(parsed)
314
+ }
315
+
316
+ // ===========================
317
+ // PRIVATE METHODS
318
+ // ===========================
319
+
320
+ /**
321
+ * Executa query de audiência live
322
+ */
323
+ private async executeLiveQuery(
324
+ _criteria: AudienceCriteria,
325
+ options: AudienceQueryOptions
326
+ ): Promise<AudienceExecutionResult> {
327
+ const startTime = Date.now()
328
+
329
+ if (!this.memberRepository) {
330
+ throw new Error('MemberRepository não configurado para audiences live')
331
+ }
332
+
333
+ const { data: members, count } = await this.memberRepository.listMembers(
334
+ '',
335
+ options.organizationId,
336
+ options.projectId,
337
+ options.pagination
338
+ )
339
+
340
+ const contactIds = new Set<string>(members?.map((m: any) => m.id) || [])
341
+
342
+ return {
343
+ contactIds,
344
+ contacts: members || [],
345
+ count: count || 0,
346
+ metadata: {
347
+ executionTime: Date.now() - startTime,
348
+ criteriaType: 'live'
349
+ }
350
+ }
351
+ }
352
+ }
353
+
@@ -0,0 +1,88 @@
1
+ import { AudienceCriteria } from '../types'
2
+
3
+ /**
4
+ * Parser centralizado para normalizar diferentes formatos de critérios
5
+ */
6
+ export class CriteriaParser {
7
+ /**
8
+ * Parse e normaliza critérios para formato padrão
9
+ */
10
+ static parse(criteria: string | AudienceCriteria): AudienceCriteria {
11
+ let parsed: AudienceCriteria
12
+
13
+ if (typeof criteria === 'string') {
14
+ try {
15
+ parsed = JSON.parse(criteria)
16
+ } catch (error) {
17
+ console.error('❌ Erro ao fazer parse de critérios:', error)
18
+ throw new Error('Critérios inválidos')
19
+ }
20
+ } else {
21
+ parsed = criteria
22
+ }
23
+
24
+ return this.normalizeCriteria(parsed)
25
+ }
26
+
27
+ /**
28
+ * Normaliza diferentes formatos de critérios para um formato único
29
+ */
30
+ private static normalizeCriteria(criteria: AudienceCriteria): AudienceCriteria {
31
+ if (criteria.filters && Array.isArray(criteria.filters)) {
32
+ return {
33
+ ...criteria,
34
+ conditions: criteria.filters.map(filter => ({
35
+ groupId: filter.id,
36
+ operator: filter.operator as 'AND' | 'OR',
37
+ conditions: filter.conditions.map(cond => ({
38
+ field: cond.field,
39
+ operator: cond.operator,
40
+ value: cond.value,
41
+ customFieldKey: cond.customFieldKey,
42
+ logicalOperator: cond.logicalOperator as 'AND' | 'OR' | undefined
43
+ }))
44
+ }))
45
+ }
46
+ }
47
+
48
+ if (criteria.groups) {
49
+ return criteria
50
+ }
51
+
52
+ return criteria
53
+ }
54
+
55
+ /**
56
+ * Detecta o tipo de audiência
57
+ */
58
+ static getAudienceType(criteria: AudienceCriteria): 'static' | 'live' {
59
+ const liveTypes = ['live-actions', 'live-page-visit', 'live-referrer', 'live-page-count']
60
+ return liveTypes.includes(criteria.type || '') ? 'live' : 'static'
61
+ }
62
+
63
+ /**
64
+ * Valida se os critérios são válidos
65
+ */
66
+ static validate(criteria: AudienceCriteria): { valid: boolean; errors: string[] } {
67
+ const errors: string[] = []
68
+
69
+ if (!criteria || typeof criteria !== 'object') {
70
+ errors.push('Critérios devem ser um objeto')
71
+ return { valid: false, errors }
72
+ }
73
+
74
+ const hasConditions = criteria.conditions && Array.isArray(criteria.conditions)
75
+ const hasFilters = criteria.filters && Array.isArray(criteria.filters)
76
+ const hasGroups = criteria.groups && Array.isArray(criteria.groups)
77
+
78
+ if (!hasConditions && !hasFilters && !hasGroups) {
79
+ errors.push('Critérios devem conter conditions, filters ou groups')
80
+ }
81
+
82
+ return {
83
+ valid: errors.length === 0,
84
+ errors
85
+ }
86
+ }
87
+ }
88
+
@@ -0,0 +1,231 @@
1
+ import { AudienceCriteria } from '../types'
2
+
3
+ /**
4
+ * Construtor de queries para diferentes tipos de critérios
5
+ */
6
+ export class QueryBuilder {
7
+ /**
8
+ * Constrói filtros Supabase baseado nos critérios
9
+ */
10
+ static buildFilters(query: any, criteria: AudienceCriteria, organizationId: string, projectId: string): any {
11
+ query = query
12
+ .eq('organization_id', organizationId)
13
+ .eq('project_id', projectId)
14
+
15
+ return this.applyAudienceCriteria(query, criteria)
16
+ }
17
+
18
+ /**
19
+ * Aplica critérios de audiência ao query
20
+ */
21
+ private static applyAudienceCriteria(query: any, criteria: AudienceCriteria): any {
22
+ if (criteria.groups && Array.isArray(criteria.groups)) {
23
+ return this.applyGroupsCriteria(query, criteria.groups)
24
+ }
25
+
26
+ if (criteria.filters && Array.isArray(criteria.filters)) {
27
+ return this.applyFiltersCriteria(query, criteria.filters)
28
+ }
29
+
30
+ if (criteria.conditions && Array.isArray(criteria.conditions)) {
31
+ return this.applyConditionsCriteria(query, criteria.conditions)
32
+ }
33
+
34
+ return query
35
+ }
36
+
37
+ /**
38
+ * Aplica filtros no formato 'groups' (v2)
39
+ */
40
+ private static applyGroupsCriteria(query: any, groups: any[]): any {
41
+ const orConditions: string[] = []
42
+
43
+ for (const group of groups) {
44
+ if (!group.rules || !Array.isArray(group.rules)) continue
45
+
46
+ const groupConditions: string[] = []
47
+
48
+ for (const rule of group.rules) {
49
+ const condition = this.buildConditionString(rule)
50
+ if (condition) {
51
+ groupConditions.push(condition)
52
+ }
53
+ }
54
+
55
+ if (groupConditions.length > 0) {
56
+ orConditions.push(groupConditions.join(','))
57
+ }
58
+ }
59
+
60
+ if (orConditions.length > 0) {
61
+ query = query.or(orConditions.join(','))
62
+ }
63
+
64
+ return query
65
+ }
66
+
67
+ /**
68
+ * Aplica filtros no formato 'filters' (v1)
69
+ */
70
+ private static applyFiltersCriteria(query: any, filters: any[]): any {
71
+ const orGroups = filters.filter(f => f.operator === 'OR')
72
+ const andGroups = filters.filter(f => f.operator !== 'OR')
73
+
74
+ if (orGroups.length > 0) {
75
+ const orConditions: string[] = []
76
+
77
+ for (const filterGroup of orGroups) {
78
+ const groupConditions: string[] = []
79
+
80
+ for (const condition of filterGroup.conditions) {
81
+ const condStr = this.buildConditionString(condition)
82
+ if (condStr) {
83
+ groupConditions.push(condStr)
84
+ }
85
+ }
86
+
87
+ if (groupConditions.length > 0) {
88
+ orConditions.push(groupConditions.join(','))
89
+ }
90
+ }
91
+
92
+ if (orConditions.length > 0) {
93
+ query = query.or(orConditions.join(','))
94
+ }
95
+ }
96
+
97
+ for (const filterGroup of andGroups) {
98
+ for (const condition of filterGroup.conditions) {
99
+ query = this.applyCondition(query, condition)
100
+ }
101
+ }
102
+
103
+ return query
104
+ }
105
+
106
+ /**
107
+ * Aplica critérios no formato 'conditions' (normalizado)
108
+ */
109
+ private static applyConditionsCriteria(query: any, conditions: any[]): any {
110
+ for (const group of conditions) {
111
+ if (group.operator === 'OR') {
112
+ const orConditions = group.conditions.map((c: any) => this.buildConditionString(c)).filter(Boolean)
113
+ if (orConditions.length > 0) {
114
+ query = query.or(orConditions.join(','))
115
+ }
116
+ } else {
117
+ for (const condition of group.conditions) {
118
+ query = this.applyCondition(query, condition)
119
+ }
120
+ }
121
+ }
122
+
123
+ return query
124
+ }
125
+
126
+ /**
127
+ * Aplica uma condição individual ao query
128
+ */
129
+ private static applyCondition(query: any, condition: any): any {
130
+ const { field, operator, value } = condition
131
+
132
+ const dbField = this.mapFieldToColumn(field, condition.customFieldKey)
133
+
134
+ switch (operator) {
135
+ case 'equals':
136
+ case 'eq':
137
+ return query.eq(dbField, value)
138
+ case 'not_equals':
139
+ case 'neq':
140
+ return query.neq(dbField, value)
141
+ case 'contains':
142
+ case 'ilike':
143
+ return query.ilike(dbField, `%${value}%`)
144
+ case 'not_contains':
145
+ return query.not.ilike(dbField, `%${value}%`)
146
+ case 'starts_with':
147
+ return query.ilike(dbField, `${value}%`)
148
+ case 'ends_with':
149
+ return query.ilike(dbField, `%${value}`)
150
+ case 'greater_than':
151
+ case 'gt':
152
+ return query.gt(dbField, value)
153
+ case 'less_than':
154
+ case 'lt':
155
+ return query.lt(dbField, value)
156
+ case 'is_empty':
157
+ return query.or(`${dbField}.is.null,${dbField}.eq.`)
158
+ case 'is_not_empty':
159
+ return query.not.is(dbField, null)
160
+ default:
161
+ console.warn(`⚠️ Operador desconhecido: ${operator}`)
162
+ return query
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Constrói string de condição para uso em .or()
168
+ */
169
+ private static buildConditionString(condition: any): string | null {
170
+ const { field, operator, value } = condition
171
+
172
+ if (!field || !operator) return null
173
+
174
+ const dbField = this.mapFieldToColumn(field, condition.customFieldKey)
175
+
176
+ switch (operator) {
177
+ case 'equals':
178
+ case 'eq':
179
+ return `${dbField}.eq.${value}`
180
+ case 'not_equals':
181
+ case 'neq':
182
+ return `${dbField}.neq.${value}`
183
+ case 'contains':
184
+ case 'ilike':
185
+ return `${dbField}.ilike.*${value}*`
186
+ case 'not_contains':
187
+ return `${dbField}.not.ilike.*${value}*`
188
+ case 'starts_with':
189
+ return `${dbField}.ilike.${value}*`
190
+ case 'ends_with':
191
+ return `${dbField}.ilike.*${value}`
192
+ case 'greater_than':
193
+ case 'gt':
194
+ return `${dbField}.gt.${value}`
195
+ case 'less_than':
196
+ case 'lt':
197
+ return `${dbField}.lt.${value}`
198
+ case 'is_empty':
199
+ return `${dbField}.is.null`
200
+ case 'is_not_empty':
201
+ return `${dbField}.not.is.null`
202
+ default:
203
+ return null
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Mapeia campo da audiência para coluna do banco
209
+ */
210
+ private static mapFieldToColumn(field: string, customFieldKey?: string): string {
211
+ if (field === 'custom_field' && customFieldKey) {
212
+ return `properties->>${customFieldKey}`
213
+ }
214
+
215
+ const fieldMap: Record<string, string> = {
216
+ email: 'email',
217
+ name: 'name',
218
+ phone: 'phone',
219
+ city: 'city',
220
+ state: 'state',
221
+ country: 'country',
222
+ created_at: 'created_at',
223
+ updated_at: 'updated_at',
224
+ opted_in: 'opted_in',
225
+ is_subscribed: 'is_subscribed'
226
+ }
227
+
228
+ return fieldMap[field] || field
229
+ }
230
+ }
231
+
@@ -0,0 +1,105 @@
1
+ import { AudienceCriteria, AudienceQueryOptions, AudienceExecutionResult } from '../types'
2
+ import { CriteriaParser } from '../builders/CriteriaParser'
3
+
4
+ /**
5
+ * Executor para audiências estáticas (baseadas em filtros)
6
+ *
7
+ * NOTA: Este executor precisa de uma instância de ContactRepository
8
+ * que deve ser injetada externamente quando usado no reachy-api
9
+ */
10
+ export class StaticAudienceExecutor {
11
+ private contactRepository: any
12
+
13
+ constructor(contactRepository?: any) {
14
+ this.contactRepository = contactRepository
15
+ }
16
+
17
+ /**
18
+ * Define o ContactRepository
19
+ */
20
+ setContactRepository(repository: any) {
21
+ this.contactRepository = repository
22
+ }
23
+
24
+ /**
25
+ * Executa query de audiência estática e retorna IDs de contatos
26
+ */
27
+ async execute(
28
+ criteria: AudienceCriteria,
29
+ options: AudienceQueryOptions
30
+ ): Promise<AudienceExecutionResult> {
31
+ const startTime = Date.now()
32
+
33
+ try {
34
+ if (!this.contactRepository) {
35
+ throw new Error('ContactRepository não foi configurado. Use setContactRepository() antes de executar.')
36
+ }
37
+
38
+ const parsed = CriteriaParser.parse(criteria)
39
+ const validation = CriteriaParser.validate(parsed)
40
+
41
+ if (!validation.valid) {
42
+ console.error('❌ Critérios inválidos:', validation.errors)
43
+ return {
44
+ contactIds: new Set(),
45
+ contacts: [],
46
+ count: 0,
47
+ metadata: {
48
+ executionTime: Date.now() - startTime,
49
+ criteriaType: 'static'
50
+ }
51
+ }
52
+ }
53
+
54
+ const contactIds = await this.contactRepository.getContactIdsByAudienceCriteriaV2(
55
+ options.organizationId,
56
+ options.projectId,
57
+ parsed
58
+ )
59
+
60
+ let contacts: any[] | undefined
61
+
62
+ if (options.includeCount || options.pagination) {
63
+ const { page = 1, limit = 10 } = options.pagination || {}
64
+ const contactIdsArray = Array.from(contactIds)
65
+
66
+ if (contactIdsArray.length > 0) {
67
+ const paginatedIds = contactIdsArray.slice((page - 1) * limit, page * limit)
68
+
69
+ const { data } = await this.contactRepository.findByIds(
70
+ options.organizationId,
71
+ options.projectId,
72
+ paginatedIds
73
+ )
74
+
75
+ contacts = data || []
76
+ }
77
+ }
78
+
79
+ return {
80
+ contactIds,
81
+ contacts,
82
+ count: contactIds.size,
83
+ metadata: {
84
+ executionTime: Date.now() - startTime,
85
+ criteriaType: 'static'
86
+ }
87
+ }
88
+ } catch (error) {
89
+ console.error('❌ Erro ao executar audiência estática:', error)
90
+ throw error
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Executa query e retorna apenas contagem (mais rápido)
96
+ */
97
+ async executeCount(
98
+ criteria: AudienceCriteria,
99
+ options: Omit<AudienceQueryOptions, 'pagination'>
100
+ ): Promise<number> {
101
+ const result = await this.execute(criteria, { ...options, includeCount: true })
102
+ return result.count
103
+ }
104
+ }
105
+
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { AudienceModule } from './AudienceModule'
2
+ export { CriteriaParser } from './builders/CriteriaParser'
3
+ export { QueryBuilder } from './builders/QueryBuilder'
4
+ export { StaticAudienceExecutor } from './executors/StaticAudienceExecutor'
5
+ export * from './types'
6
+
@@ -0,0 +1,112 @@
1
+ // Interface flexível para suportar múltiplos formatos de critérios de audience
2
+ export interface AudienceCriteria {
3
+ type?: string;
4
+ conditions?: Array<{
5
+ groupId?: string;
6
+ operator?: 'AND' | 'OR';
7
+ conditions?: Array<{
8
+ field: string;
9
+ operator: string;
10
+ value: any;
11
+ customFieldKey?: string;
12
+ logicalOperator?: 'AND' | 'OR';
13
+ }>;
14
+ }>;
15
+
16
+ groups?: Array<{
17
+ id?: string;
18
+ operator?: 'AND' | 'OR';
19
+ rules?: Array<{
20
+ kind?: string;
21
+ field?: string;
22
+ operator?: string;
23
+ value?: any;
24
+ eventName?: string;
25
+ customFieldKey?: string;
26
+ }>;
27
+ }>;
28
+
29
+ filters?: Array<{
30
+ id: string;
31
+ operator: string;
32
+ conditions: Array<{
33
+ id: string;
34
+ field: string;
35
+ value: string;
36
+ operator: string;
37
+ logicalOperator: string;
38
+ customFieldKey?: string;
39
+ }>;
40
+ }>;
41
+ segmentType?: string;
42
+
43
+ config?: {
44
+ name?: string;
45
+ type?: string;
46
+ pageUrl?: string;
47
+ isActive?: boolean;
48
+ eventType?: string;
49
+ pageCount?: string;
50
+ timeFrame?: string;
51
+ conditions?: any[];
52
+ description?: string;
53
+ referrerUrl?: string;
54
+ behaviorCriteria?: {
55
+ engaged?: boolean;
56
+ purchased?: boolean;
57
+ visitedPages?: boolean;
58
+ };
59
+ };
60
+
61
+ live_options?: {
62
+ include_past_behavior?: boolean;
63
+ window_days?: number;
64
+ [key: string]: any;
65
+ };
66
+
67
+ isActive?: boolean;
68
+ [key: string]: any;
69
+ }
70
+
71
+ export interface AudienceQueryOptions {
72
+ organizationId: string
73
+ projectId: string
74
+ pagination?: {
75
+ page?: number
76
+ limit?: number
77
+ }
78
+ includeCount?: boolean
79
+ }
80
+
81
+ export interface AudienceExecutionResult {
82
+ contactIds: Set<string>
83
+ contacts?: any[]
84
+ count: number
85
+ metadata?: {
86
+ executionTime: number
87
+ criteriaType: string
88
+ cacheHit?: boolean
89
+ }
90
+ }
91
+
92
+ export interface AudienceBuilderConfig {
93
+ enableCache?: boolean
94
+ cacheTimeout?: number
95
+ enableDebugLogs?: boolean
96
+ }
97
+
98
+ export interface CreateAudienceData {
99
+ name: string;
100
+ description?: string;
101
+ criteria: AudienceCriteria;
102
+ organization_id: string;
103
+ project_id: string;
104
+ user_id: string;
105
+ }
106
+
107
+ export interface UpdateAudienceData {
108
+ name?: string;
109
+ description?: string;
110
+ criteria?: AudienceCriteria;
111
+ }
112
+
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "moduleResolution": "node",
15
+ "resolveJsonModule": true,
16
+ "removeComments": true,
17
+ "sourceMap": true,
18
+ "noImplicitAny": false,
19
+ "noImplicitReturns": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true
22
+ },
23
+ "include": ["src/**/*"],
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
25
+ }
26
+