@reachy/audience-module 1.0.12 → 1.0.14

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.
@@ -2,6 +2,288 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.V2AudienceEngine = void 0;
4
4
  const CriteriaParser_1 = require("../builders/CriteriaParser");
5
+ const getByPath = (obj, dottedPath) => {
6
+ if (!obj || !dottedPath)
7
+ return undefined;
8
+ const parts = dottedPath.split('.').filter(Boolean);
9
+ let cur = obj;
10
+ for (const p of parts) {
11
+ if (cur == null)
12
+ return undefined;
13
+ cur = cur[p];
14
+ }
15
+ return cur;
16
+ };
17
+ const getEventPropertyValue = (eventData, keyRaw) => {
18
+ const key = String(keyRaw || '').trim();
19
+ if (!key)
20
+ return undefined;
21
+ const ed = eventData || {};
22
+ if (key.startsWith('event_data.'))
23
+ return getByPath(ed, key.replace(/^event_data\./, ''));
24
+ if (key.startsWith('custom_data.'))
25
+ return getByPath(ed?.custom_data, key.replace(/^custom_data\./, ''));
26
+ if (key.startsWith('utm_data.'))
27
+ return getByPath(ed?.utm_data, key.replace(/^utm_data\./, ''));
28
+ if (key.startsWith('url_data.'))
29
+ return getByPath(ed?.url_data, key.replace(/^url_data\./, ''));
30
+ if (key.startsWith('session_data.'))
31
+ return getByPath(ed?.session_data, key.replace(/^session_data\./, ''));
32
+ return getByPath(ed, key) ?? getByPath(ed?.custom_data, key);
33
+ };
34
+ const isValidIanaTimezone = (tzRaw) => {
35
+ const tz = String(tzRaw || '').trim();
36
+ if (!tz)
37
+ return false;
38
+ try {
39
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ };
46
+ const parseTimeToMinutes = (raw) => {
47
+ const s = String(raw || '').trim();
48
+ if (!s)
49
+ return null;
50
+ const m = s.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
51
+ if (!m)
52
+ return null;
53
+ let hh = Number(m[1]);
54
+ const mm = Number(m[2]);
55
+ const ampm = m[3] ? String(m[3]).toUpperCase() : null;
56
+ if (!Number.isFinite(hh) || !Number.isFinite(mm))
57
+ return null;
58
+ if (mm < 0 || mm > 59)
59
+ return null;
60
+ if (ampm) {
61
+ if (hh < 1 || hh > 12)
62
+ return null;
63
+ if (ampm === 'AM')
64
+ hh = hh === 12 ? 0 : hh;
65
+ if (ampm === 'PM')
66
+ hh = hh === 12 ? 12 : hh + 12;
67
+ }
68
+ else {
69
+ if (hh < 0 || hh > 23)
70
+ return null;
71
+ }
72
+ return hh * 60 + mm;
73
+ };
74
+ const getLocalTimeParts = (iso, tz) => {
75
+ const d = new Date(String(iso || ''));
76
+ if (Number.isNaN(d.getTime()))
77
+ return null;
78
+ const dtf = new Intl.DateTimeFormat('en-US', {
79
+ timeZone: tz,
80
+ hour: '2-digit',
81
+ minute: '2-digit',
82
+ hour12: false,
83
+ weekday: 'short',
84
+ day: '2-digit',
85
+ });
86
+ const parts = dtf.formatToParts(d);
87
+ let hourStr = '';
88
+ let minStr = '';
89
+ let weekdayStr = '';
90
+ let dayStr = '';
91
+ for (const p of parts) {
92
+ if (p.type === 'hour')
93
+ hourStr = p.value;
94
+ if (p.type === 'minute')
95
+ minStr = p.value;
96
+ if (p.type === 'weekday')
97
+ weekdayStr = p.value;
98
+ if (p.type === 'day')
99
+ dayStr = p.value;
100
+ }
101
+ const hour = Number(hourStr);
102
+ const minute = Number(minStr);
103
+ const dayOfMonth = Number(dayStr);
104
+ if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(dayOfMonth))
105
+ return null;
106
+ const minutes = hour * 60 + minute;
107
+ const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
108
+ const weekday = weekdayMap[weekdayStr] ?? -1;
109
+ if (weekday < 0)
110
+ return null;
111
+ return { minutes, weekday, dayOfMonth };
112
+ };
113
+ const parseWeekdayToIndex = (raw) => {
114
+ const s = String(raw || '').trim().toLowerCase();
115
+ if (!s)
116
+ return null;
117
+ const map = {
118
+ sunday: 0, sun: 0,
119
+ monday: 1, mon: 1,
120
+ tuesday: 2, tue: 2, tues: 2,
121
+ wednesday: 3, wed: 3,
122
+ thursday: 4, thu: 4, thurs: 4,
123
+ friday: 5, fri: 5,
124
+ saturday: 6, sat: 6,
125
+ };
126
+ return map[s] ?? null;
127
+ };
128
+ const matchesInterest = (raw, op, expected) => {
129
+ if (raw === null || raw === undefined)
130
+ return false;
131
+ const a = String(raw).trim();
132
+ const b = String(expected ?? '').trim();
133
+ if (!b)
134
+ return false;
135
+ const nOp = String(op || 'equals').trim();
136
+ if (nOp === 'equals')
137
+ return a === b;
138
+ if (nOp === 'contains')
139
+ return a.toLowerCase().includes(b.toLowerCase());
140
+ if (nOp === 'starts_with')
141
+ return a.toLowerCase().startsWith(b.toLowerCase());
142
+ if (nOp === 'ends_with')
143
+ return a.toLowerCase().endsWith(b.toLowerCase());
144
+ if (nOp === 'not_equals')
145
+ return a !== b;
146
+ return a.toLowerCase().includes(b.toLowerCase());
147
+ };
148
+ const normalizeEventPropertyOp = (opRaw) => {
149
+ const op = String(opRaw ?? '').trim();
150
+ if (op === '=' || op === '==' || op === 'eq' || op === 'equals')
151
+ return 'equals';
152
+ if (op === '!=' || op === '<>' || op === 'neq' || op === 'not_equals')
153
+ return 'not_equals';
154
+ if (op === 'contains')
155
+ return 'contains';
156
+ if (op === 'not_contains')
157
+ return 'not_contains';
158
+ if (op === 'starts_with')
159
+ return 'starts_with';
160
+ if (op === 'ends_with')
161
+ return 'ends_with';
162
+ if (op === 'is_empty')
163
+ return 'is_empty';
164
+ if (op === 'is_not_empty')
165
+ return 'is_not_empty';
166
+ if (op === '>' || op === 'gt')
167
+ return 'gt';
168
+ if (op === '>=' || op === 'gte')
169
+ return 'gte';
170
+ if (op === '<' || op === 'lt')
171
+ return 'lt';
172
+ if (op === '<=' || op === 'lte')
173
+ return 'lte';
174
+ return op;
175
+ };
176
+ const matchesEventPropertyFilter = (raw, opRaw, expected) => {
177
+ const op = normalizeEventPropertyOp(opRaw);
178
+ if (op === 'is_empty') {
179
+ return raw === null || raw === undefined || String(raw).trim() === '';
180
+ }
181
+ if (op === 'is_not_empty') {
182
+ return raw !== null && raw !== undefined && String(raw).trim() !== '';
183
+ }
184
+ if (raw === null || raw === undefined)
185
+ return false;
186
+ const a = String(raw);
187
+ const b = String(expected ?? '');
188
+ if (op === 'equals')
189
+ return a === b;
190
+ if (op === 'not_equals')
191
+ return a !== b;
192
+ if (op === 'contains')
193
+ return a.toLowerCase().includes(b.toLowerCase());
194
+ if (op === 'not_contains')
195
+ return !a.toLowerCase().includes(b.toLowerCase());
196
+ if (op === 'starts_with')
197
+ return a.toLowerCase().startsWith(b.toLowerCase());
198
+ if (op === 'ends_with')
199
+ return a.toLowerCase().endsWith(b.toLowerCase());
200
+ if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') {
201
+ const aNum = Number(raw);
202
+ const bNum = Number(expected);
203
+ if (!Number.isFinite(aNum) || !Number.isFinite(bNum))
204
+ return false;
205
+ if (op === 'gt')
206
+ return aNum > bNum;
207
+ if (op === 'gte')
208
+ return aNum >= bNum;
209
+ if (op === 'lt')
210
+ return aNum < bNum;
211
+ return aNum <= bNum;
212
+ }
213
+ return false;
214
+ };
215
+ const applyEventRuleFilters = (rows, filters, opts) => {
216
+ let out = rows;
217
+ const tz = String(opts?.timezone || '').trim();
218
+ for (const f of filters) {
219
+ const type = String(f?.type || '').trim();
220
+ if (type === 'event_property') {
221
+ const key = String(f?.key || '').trim();
222
+ if (!key)
223
+ continue;
224
+ const op = f?.op;
225
+ const value = f?.value;
226
+ out = out.filter((row) => matchesEventPropertyFilter(getEventPropertyValue(row?.event_data, key), op, value));
227
+ }
228
+ else if (type === 'time_of_day') {
229
+ if (!tz)
230
+ continue;
231
+ const startMin = parseTimeToMinutes(f?.timeStart);
232
+ const endMin = parseTimeToMinutes(f?.timeEnd);
233
+ if (startMin == null || endMin == null)
234
+ continue;
235
+ if (startMin > endMin) {
236
+ out = [];
237
+ continue;
238
+ }
239
+ out = out.filter((row) => {
240
+ const parts = getLocalTimeParts(row?.event_timestamp, tz);
241
+ if (!parts)
242
+ return false;
243
+ return parts.minutes >= startMin && parts.minutes <= endMin;
244
+ });
245
+ }
246
+ else if (type === 'day_of_week') {
247
+ if (!tz)
248
+ continue;
249
+ const days = Array.isArray(f?.days) ? f.days : [];
250
+ const allowed = new Set();
251
+ for (const d of days) {
252
+ const idx = parseWeekdayToIndex(d);
253
+ if (idx != null)
254
+ allowed.add(idx);
255
+ }
256
+ if (allowed.size === 0)
257
+ continue;
258
+ out = out.filter((row) => {
259
+ const parts = getLocalTimeParts(row?.event_timestamp, tz);
260
+ if (!parts)
261
+ return false;
262
+ return allowed.has(parts.weekday);
263
+ });
264
+ }
265
+ else if (type === 'day_of_month') {
266
+ if (!tz)
267
+ continue;
268
+ const days = Array.isArray(f?.days) ? f.days : [];
269
+ const allowed = new Set();
270
+ for (const d of days) {
271
+ const n = Number(d);
272
+ if (Number.isFinite(n) && n >= 1 && n <= 31)
273
+ allowed.add(Math.floor(n));
274
+ }
275
+ if (allowed.size === 0)
276
+ continue;
277
+ out = out.filter((row) => {
278
+ const parts = getLocalTimeParts(row?.event_timestamp, tz);
279
+ if (!parts)
280
+ return false;
281
+ return allowed.has(parts.dayOfMonth);
282
+ });
283
+ }
284
+ }
285
+ return out;
286
+ };
5
287
  class V2AudienceEngine {
6
288
  constructor(params) {
7
289
  this.supabase = params.supabaseClient;
@@ -10,6 +292,21 @@ class V2AudienceEngine {
10
292
  }
11
293
  async getContactIdsByAudienceCriteriaV2(organizationId, projectId, criteriaRaw) {
12
294
  let criteria = CriteriaParser_1.CriteriaParser.parse(criteriaRaw);
295
+ let projectTimezone = 'UTC';
296
+ try {
297
+ const { data } = await this.supabase
298
+ .from('projects')
299
+ .select('settings')
300
+ .eq('organization_id', organizationId)
301
+ .eq('id', projectId)
302
+ .single();
303
+ const tzCandidate = data?.settings?.timezone;
304
+ if (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate)) {
305
+ projectTimezone = tzCandidate.trim();
306
+ }
307
+ }
308
+ catch {
309
+ }
13
310
  if ((!Array.isArray(criteria.groups) || criteria.groups.length === 0) &&
14
311
  (Array.isArray(criteria.filters) || Array.isArray(criteria.conditions))) {
15
312
  criteria = coerceCriteriaToGroups(criteria);
@@ -32,12 +329,15 @@ class V2AudienceEngine {
32
329
  .eq('project_id', projectId);
33
330
  const allContactIds = new Set((allContacts || []).map((c) => c.id));
34
331
  const reachyIdToContactId = new Map();
332
+ const contactIdToReachyId = new Map();
35
333
  const emailToContactId = new Map();
36
334
  for (const c of allContacts || []) {
37
335
  const rid = c?.reachy_id ? String(c.reachy_id).trim() : '';
38
336
  const cid = c?.id ? String(c.id).trim() : '';
39
- if (rid && cid)
337
+ if (rid && cid) {
40
338
  reachyIdToContactId.set(rid, cid);
339
+ contactIdToReachyId.set(cid, rid);
340
+ }
41
341
  const email = c?.email ? String(c.email).trim().toLowerCase() : '';
42
342
  if (email && cid)
43
343
  emailToContactId.set(email, cid);
@@ -190,8 +490,13 @@ class V2AudienceEngine {
190
490
  }
191
491
  }
192
492
  }
493
+ const interest = rule?.interest;
193
494
  try {
194
- const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : []);
495
+ let attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : []);
496
+ const interestKey = interest?.key ? String(interest.key).trim() : '';
497
+ if (interestKey) {
498
+ attributes = attributes.filter((a) => String(a?.key || '').trim() !== interestKey);
499
+ }
195
500
  const trackerEvents = new Set([
196
501
  'click',
197
502
  'page_view',
@@ -275,10 +580,229 @@ class V2AudienceEngine {
275
580
  const { data, error } = await query;
276
581
  if (error)
277
582
  return new Set();
583
+ let rows = (data || []);
584
+ const ruleFiltersAll = Array.isArray(rule.filters) ? rule.filters : [];
585
+ const hasFirstTime = ruleFiltersAll.some((f) => String(f?.type || '').trim() === 'first_time');
586
+ const hasLastTime = ruleFiltersAll.some((f) => String(f?.type || '').trim() === 'last_time');
587
+ const ruleFilters = ruleFiltersAll.filter((f) => {
588
+ const t = String(f?.type || '').trim();
589
+ return t && t !== 'first_time' && t !== 'last_time';
590
+ });
591
+ if (ruleFilters.length > 0) {
592
+ rows = applyEventRuleFilters(rows, ruleFilters, { timezone: projectTimezone });
593
+ }
594
+ if ((hasFirstTime || hasLastTime) && rows.length > 0) {
595
+ const now = new Date();
596
+ let windowStartIso;
597
+ let windowEndIso;
598
+ if (rule.time) {
599
+ if (rule.time.unit && rule.time.value != null) {
600
+ const unit = String(rule.time.unit);
601
+ const valueNum = Number(rule.time.value);
602
+ const units = {
603
+ minutes: 60 * 1000,
604
+ hours: 60 * 60 * 1000,
605
+ days: 24 * 60 * 60 * 1000,
606
+ weeks: 7 * 24 * 60 * 60 * 1000,
607
+ months: 30 * 24 * 60 * 60 * 1000,
608
+ };
609
+ const ms = units[unit] ?? units['days'];
610
+ if (Number.isFinite(valueNum) && valueNum >= 0) {
611
+ windowStartIso = new Date(now.getTime() - valueNum * ms).toISOString();
612
+ windowEndIso = now.toISOString();
613
+ }
614
+ }
615
+ else {
616
+ if (rule.time.from)
617
+ windowStartIso = String(rule.time.from);
618
+ if (rule.time.to)
619
+ windowEndIso = String(rule.time.to);
620
+ }
621
+ }
622
+ else if (cfg && cfg.timeFrame) {
623
+ const tf = String(cfg.timeFrame).trim();
624
+ let unit = 'days';
625
+ let value = 7;
626
+ if (/^\d+$/.test(tf)) {
627
+ value = Number(tf);
628
+ unit = 'days';
629
+ }
630
+ else if (/^\d+\s*m$/.test(tf)) {
631
+ value = Number(tf.replace(/m$/, ''));
632
+ unit = 'minutes';
633
+ }
634
+ else if (/^\d+\s*h$/.test(tf)) {
635
+ value = Number(tf.replace(/h$/, ''));
636
+ unit = 'hours';
637
+ }
638
+ else if (/^\d+\s*d$/.test(tf)) {
639
+ value = Number(tf.replace(/d$/, ''));
640
+ unit = 'days';
641
+ }
642
+ else if (/^\d+\s*w$/.test(tf)) {
643
+ value = Number(tf.replace(/w$/, ''));
644
+ unit = 'weeks';
645
+ }
646
+ else if (/^\d+\s*mo$/.test(tf)) {
647
+ value = Number(tf.replace(/mo$/, ''));
648
+ unit = 'months';
649
+ }
650
+ const units = {
651
+ minutes: 60 * 1000,
652
+ hours: 60 * 60 * 1000,
653
+ days: 24 * 60 * 60 * 1000,
654
+ weeks: 7 * 24 * 60 * 60 * 1000,
655
+ months: 30 * 24 * 60 * 60 * 1000,
656
+ };
657
+ if (Number.isFinite(value) && value >= 0) {
658
+ const from = new Date(now.getTime() - value * units[unit]);
659
+ windowStartIso = from.toISOString();
660
+ windowEndIso = now.toISOString();
661
+ }
662
+ }
663
+ if (!windowEndIso)
664
+ windowEndIso = now.toISOString();
665
+ const candidateIds = new Set();
666
+ for (const row of rows) {
667
+ const id = resolveEventContactId(row);
668
+ if (id)
669
+ candidateIds.add(id);
670
+ }
671
+ if (candidateIds.size === 0) {
672
+ return new Set();
673
+ }
674
+ const candidateArr = Array.from(candidateIds);
675
+ const CHUNK = 500;
676
+ const PAGE = 1000;
677
+ const fetchOutside = async (dir, boundaryIso) => {
678
+ const outIds = new Set();
679
+ for (let i = 0; i < candidateArr.length; i += CHUNK) {
680
+ const chunkIds = candidateArr.slice(i, i + CHUNK);
681
+ const chunkReachyIds = chunkIds.map((cid) => contactIdToReachyId.get(cid)).filter(Boolean);
682
+ const runPaged = async (queryBuilderFactory) => {
683
+ let offset = 0;
684
+ while (true) {
685
+ const query = queryBuilderFactory(offset, offset + PAGE - 1);
686
+ const { data: rowsPage, error: pageError } = await query;
687
+ if (pageError || !rowsPage || rowsPage.length === 0)
688
+ break;
689
+ for (const r of rowsPage) {
690
+ const cid = resolveEventContactId(r);
691
+ if (cid)
692
+ outIds.add(cid);
693
+ }
694
+ if (outIds.size >= candidateIds.size)
695
+ return;
696
+ if (rowsPage.length < PAGE)
697
+ break;
698
+ offset += PAGE;
699
+ if (offset > 50000)
700
+ break;
701
+ }
702
+ };
703
+ await runPaged((from, to) => {
704
+ let q = this.supabase
705
+ .from('contact_events')
706
+ .select('contact_id, reachy_id, event_timestamp')
707
+ .eq('organization_id', organizationId)
708
+ .eq('project_id', projectId)
709
+ .eq('event_name', effectiveEventName);
710
+ q = dir === 'before' ? q.lt('event_timestamp', boundaryIso) : q.gt('event_timestamp', boundaryIso);
711
+ q = q.in('contact_id', chunkIds);
712
+ return q.range(from, to);
713
+ });
714
+ if (outIds.size >= candidateIds.size)
715
+ break;
716
+ if (chunkReachyIds.length > 0) {
717
+ await runPaged((from, to) => {
718
+ let q = this.supabase
719
+ .from('contact_events')
720
+ .select('contact_id, reachy_id, event_timestamp')
721
+ .eq('organization_id', organizationId)
722
+ .eq('project_id', projectId)
723
+ .eq('event_name', effectiveEventName);
724
+ q = dir === 'before' ? q.lt('event_timestamp', boundaryIso) : q.gt('event_timestamp', boundaryIso);
725
+ q = q.in('reachy_id', chunkReachyIds);
726
+ return q.range(from, to);
727
+ });
728
+ }
729
+ if (outIds.size >= candidateIds.size)
730
+ break;
731
+ }
732
+ return outIds;
733
+ };
734
+ let allowed = new Set(candidateIds);
735
+ if (hasFirstTime && windowStartIso) {
736
+ const hadBefore = await fetchOutside('before', windowStartIso);
737
+ allowed = diff(allowed, hadBefore);
738
+ }
739
+ if (hasLastTime && windowEndIso) {
740
+ const hadAfter = await fetchOutside('after', windowEndIso);
741
+ allowed = diff(allowed, hadAfter);
742
+ }
743
+ if (allowed.size === 0) {
744
+ return new Set();
745
+ }
746
+ rows = rows.filter((row) => {
747
+ const id = resolveEventContactId(row);
748
+ return id ? allowed.has(id) : false;
749
+ });
750
+ }
751
+ if (interest && interest.key) {
752
+ const key = String(interest.key || '').trim();
753
+ const op = String(interest.op || 'equals').trim();
754
+ const expected = interest.value;
755
+ const occType = String(interest.occurrenceType || 'predominantly').trim();
756
+ const pct = Number(interest.occurrencePercentage ?? 0);
757
+ const threshold = Number.isFinite(pct) ? Math.max(0, Math.min(100, pct)) / 100 : 0;
758
+ const totals = new Map();
759
+ const matchCounts = new Map();
760
+ const valueCounts = new Map();
761
+ for (const row of rows) {
762
+ const id = resolveEventContactId(row);
763
+ if (!id)
764
+ continue;
765
+ totals.set(id, (totals.get(id) || 0) + 1);
766
+ const rawVal = getEventPropertyValue(row?.event_data, key);
767
+ const strVal = rawVal === null || rawVal === undefined ? '' : String(rawVal);
768
+ if (!valueCounts.has(id))
769
+ valueCounts.set(id, new Map());
770
+ const per = valueCounts.get(id);
771
+ per.set(strVal, (per.get(strVal) || 0) + 1);
772
+ if (matchesInterest(rawVal, op === 'contains' ? 'contains' : op, expected)) {
773
+ matchCounts.set(id, (matchCounts.get(id) || 0) + 1);
774
+ }
775
+ }
776
+ const res = new Set();
777
+ totals.forEach((total, id) => {
778
+ if (total <= 0)
779
+ return;
780
+ const match = matchCounts.get(id) || 0;
781
+ if (occType === 'at_least') {
782
+ if (match > 0 && match / total >= threshold)
783
+ res.add(id);
784
+ return;
785
+ }
786
+ const per = valueCounts.get(id);
787
+ if (!per)
788
+ return;
789
+ let maxOther = 0;
790
+ per.forEach((cnt, valStr) => {
791
+ if (!matchesInterest(valStr, op, expected)) {
792
+ if (cnt > maxOther)
793
+ maxOther = cnt;
794
+ }
795
+ });
796
+ if (match > 0 && match >= maxOther)
797
+ res.add(id);
798
+ });
799
+ return res;
800
+ }
278
801
  if (rule.frequency && rule.frequency.value != null) {
279
- const { op, value, type = 'count', field = 'value' } = rule.frequency;
802
+ const { op, value, value2, type = 'count', field = 'value' } = rule.frequency;
280
803
  const counts = new Map();
281
- for (const row of data || []) {
804
+ const denoms = new Map();
805
+ for (const row of rows) {
282
806
  const id = resolveEventContactId(row);
283
807
  if (!id)
284
808
  continue;
@@ -286,16 +810,16 @@ class V2AudienceEngine {
286
810
  counts.set(id, (counts.get(id) || 0) + 1);
287
811
  }
288
812
  else if (type === 'sum' || type === 'avg') {
289
- let numVal = 0;
290
813
  const eventData = row.event_data || {};
291
- if (eventData[field] !== undefined)
292
- numVal = Number(eventData[field]);
293
- else if (eventData['value'] !== undefined)
294
- numVal = Number(eventData['value']);
295
- else if (eventData['amount'] !== undefined)
296
- numVal = Number(eventData['amount']);
297
- if (!Number.isNaN(numVal)) {
814
+ const raw = getEventPropertyValue(eventData, field) ??
815
+ (eventData?.[field] !== undefined ? eventData[field] : undefined) ??
816
+ eventData?.value ??
817
+ eventData?.amount;
818
+ const numVal = Number(raw);
819
+ if (Number.isFinite(numVal)) {
298
820
  counts.set(id, (counts.get(id) || 0) + numVal);
821
+ if (type === 'avg')
822
+ denoms.set(id, (denoms.get(id) || 0) + 1);
299
823
  }
300
824
  }
301
825
  }
@@ -303,20 +827,27 @@ class V2AudienceEngine {
303
827
  counts.forEach((aggregatedValue, id) => {
304
828
  let finalValue = aggregatedValue;
305
829
  if (type === 'avg') {
306
- let eventCount = 0;
307
- for (const row of data || []) {
308
- const rid = resolveEventContactId(row);
309
- if (rid === id)
310
- eventCount++;
311
- }
312
- finalValue = eventCount > 0 ? aggregatedValue / eventCount : 0;
830
+ const denom = denoms.get(id) || 0;
831
+ finalValue = denom > 0 ? aggregatedValue / denom : 0;
313
832
  }
833
+ const betweenOk = (() => {
834
+ if (op !== 'between')
835
+ return false;
836
+ const a = Number(value);
837
+ const b = Number(value2);
838
+ if (!Number.isFinite(a) || !Number.isFinite(b))
839
+ return false;
840
+ if (a > b)
841
+ return false;
842
+ return finalValue >= a && finalValue <= b;
843
+ })();
314
844
  if ((op === '>=' && finalValue >= value) ||
315
845
  (op === '>' && finalValue > value) ||
316
846
  (op === '=' && finalValue === value) ||
317
847
  (op === '!=' && finalValue !== value) ||
318
848
  (op === '<=' && finalValue <= value) ||
319
- (op === '<' && finalValue < value)) {
849
+ (op === '<' && finalValue < value) ||
850
+ betweenOk) {
320
851
  res.add(id);
321
852
  }
322
853
  });
@@ -325,7 +856,7 @@ class V2AudienceEngine {
325
856
  if (typeId === 'live-page-count' && cfg && cfg.pageCount != null) {
326
857
  const threshold = Number(cfg.pageCount);
327
858
  const counts = new Map();
328
- for (const row of data || []) {
859
+ for (const row of rows) {
329
860
  const id = resolveEventContactId(row);
330
861
  if (!id)
331
862
  continue;
@@ -337,7 +868,7 @@ class V2AudienceEngine {
337
868
  return res;
338
869
  }
339
870
  const res = new Set();
340
- for (const row of data || []) {
871
+ for (const row of rows) {
341
872
  const id = resolveEventContactId(row);
342
873
  if (id)
343
874
  res.add(id);
@@ -606,6 +1137,21 @@ class V2AudienceEngine {
606
1137
  return false;
607
1138
  }
608
1139
  const typeId = String(criteria?.type || '');
1140
+ let projectTimezone = 'UTC';
1141
+ try {
1142
+ const { data } = await this.supabase
1143
+ .from('projects')
1144
+ .select('settings')
1145
+ .eq('organization_id', organizationId)
1146
+ .eq('id', projectId)
1147
+ .single();
1148
+ const tzCandidate = data?.settings?.timezone;
1149
+ if (typeof tzCandidate === 'string' && isValidIanaTimezone(tzCandidate)) {
1150
+ projectTimezone = tzCandidate.trim();
1151
+ }
1152
+ }
1153
+ catch {
1154
+ }
609
1155
  const { data: contactRow } = await this.supabase
610
1156
  .from('contacts')
611
1157
  .select('id, reachy_id, email')
@@ -742,8 +1288,13 @@ class V2AudienceEngine {
742
1288
  }
743
1289
  }
744
1290
  }
1291
+ const interest = rule?.interest;
745
1292
  try {
746
- const attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : []);
1293
+ let attributes = Array.isArray(rule.attributes) ? rule.attributes : (rule.attributes ? [rule.attributes] : []);
1294
+ const interestKey = interest?.key ? String(interest.key).trim() : '';
1295
+ if (interestKey) {
1296
+ attributes = attributes.filter((a) => String(a?.key || '').trim() !== interestKey);
1297
+ }
747
1298
  const resolveDbFieldForAttrKey = (keyRaw) => {
748
1299
  const key = String(keyRaw || '').trim();
749
1300
  if (!key)
@@ -814,28 +1365,192 @@ class V2AudienceEngine {
814
1365
  const { data, error } = await query;
815
1366
  if (error)
816
1367
  return false;
817
- const rows = data || [];
1368
+ let rows = (data || []);
1369
+ const ruleFiltersAll = Array.isArray(rule.filters) ? rule.filters : [];
1370
+ const hasFirstTime = ruleFiltersAll.some((f) => String(f?.type || '').trim() === 'first_time');
1371
+ const hasLastTime = ruleFiltersAll.some((f) => String(f?.type || '').trim() === 'last_time');
1372
+ const ruleFilters = ruleFiltersAll.filter((f) => {
1373
+ const t = String(f?.type || '').trim();
1374
+ return t && t !== 'first_time' && t !== 'last_time';
1375
+ });
1376
+ if (ruleFilters.length > 0) {
1377
+ rows = applyEventRuleFilters(rows, ruleFilters, { timezone: projectTimezone });
1378
+ }
1379
+ if ((hasFirstTime || hasLastTime) && rows.length > 0) {
1380
+ const now = new Date();
1381
+ let windowStartIso;
1382
+ let windowEndIso;
1383
+ if (rule.time) {
1384
+ if (rule.time.unit && rule.time.value != null) {
1385
+ const unit = String(rule.time.unit);
1386
+ const valueNum = Number(rule.time.value);
1387
+ const units = {
1388
+ minutes: 60 * 1000,
1389
+ hours: 60 * 60 * 1000,
1390
+ days: 24 * 60 * 60 * 1000,
1391
+ weeks: 7 * 24 * 60 * 60 * 1000,
1392
+ months: 30 * 24 * 60 * 60 * 1000,
1393
+ };
1394
+ const ms = units[unit] ?? units['days'];
1395
+ if (Number.isFinite(valueNum) && valueNum >= 0) {
1396
+ windowStartIso = new Date(now.getTime() - valueNum * ms).toISOString();
1397
+ windowEndIso = now.toISOString();
1398
+ }
1399
+ }
1400
+ else {
1401
+ if (rule.time.from)
1402
+ windowStartIso = String(rule.time.from);
1403
+ if (rule.time.to)
1404
+ windowEndIso = String(rule.time.to);
1405
+ }
1406
+ }
1407
+ else if (cfg && cfg.timeFrame) {
1408
+ const tf = String(cfg.timeFrame).trim();
1409
+ let unit = 'days';
1410
+ let value = 7;
1411
+ if (/^\d+$/.test(tf)) {
1412
+ value = Number(tf);
1413
+ unit = 'days';
1414
+ }
1415
+ else if (/^\d+\s*m$/.test(tf)) {
1416
+ value = Number(tf.replace(/m$/, ''));
1417
+ unit = 'minutes';
1418
+ }
1419
+ else if (/^\d+\s*h$/.test(tf)) {
1420
+ value = Number(tf.replace(/h$/, ''));
1421
+ unit = 'hours';
1422
+ }
1423
+ else if (/^\d+\s*d$/.test(tf)) {
1424
+ value = Number(tf.replace(/d$/, ''));
1425
+ unit = 'days';
1426
+ }
1427
+ else if (/^\d+\s*w$/.test(tf)) {
1428
+ value = Number(tf.replace(/w$/, ''));
1429
+ unit = 'weeks';
1430
+ }
1431
+ else if (/^\d+\s*mo$/.test(tf)) {
1432
+ value = Number(tf.replace(/mo$/, ''));
1433
+ unit = 'months';
1434
+ }
1435
+ const units = {
1436
+ minutes: 60 * 1000,
1437
+ hours: 60 * 60 * 1000,
1438
+ days: 24 * 60 * 60 * 1000,
1439
+ weeks: 7 * 24 * 60 * 60 * 1000,
1440
+ months: 30 * 24 * 60 * 60 * 1000,
1441
+ };
1442
+ if (Number.isFinite(value) && value >= 0) {
1443
+ const from = new Date(now.getTime() - value * units[unit]);
1444
+ windowStartIso = from.toISOString();
1445
+ windowEndIso = now.toISOString();
1446
+ }
1447
+ }
1448
+ if (!windowEndIso)
1449
+ windowEndIso = now.toISOString();
1450
+ if (hasFirstTime && windowStartIso) {
1451
+ let q = this.supabase
1452
+ .from('contact_events')
1453
+ .select('id')
1454
+ .eq('organization_id', organizationId)
1455
+ .eq('project_id', projectId)
1456
+ .eq('event_name', effectiveEventName)
1457
+ .lt('event_timestamp', windowStartIso)
1458
+ .limit(1);
1459
+ const ors2 = [];
1460
+ if (contactId)
1461
+ ors2.push(`contact_id.eq.${contactId}`);
1462
+ if (reachyId)
1463
+ ors2.push(`reachy_id.eq.${reachyId}`);
1464
+ if (ors2.length > 0)
1465
+ q = q.or(ors2.join(','));
1466
+ const { data: beforeRows } = await q;
1467
+ if (Array.isArray(beforeRows) && beforeRows.length > 0)
1468
+ return false;
1469
+ }
1470
+ if (hasLastTime && windowEndIso) {
1471
+ let q = this.supabase
1472
+ .from('contact_events')
1473
+ .select('id')
1474
+ .eq('organization_id', organizationId)
1475
+ .eq('project_id', projectId)
1476
+ .eq('event_name', effectiveEventName)
1477
+ .gt('event_timestamp', windowEndIso)
1478
+ .limit(1);
1479
+ const ors2 = [];
1480
+ if (contactId)
1481
+ ors2.push(`contact_id.eq.${contactId}`);
1482
+ if (reachyId)
1483
+ ors2.push(`reachy_id.eq.${reachyId}`);
1484
+ if (ors2.length > 0)
1485
+ q = q.or(ors2.join(','));
1486
+ const { data: afterRows } = await q;
1487
+ if (Array.isArray(afterRows) && afterRows.length > 0)
1488
+ return false;
1489
+ }
1490
+ }
1491
+ if (interest && interest.key) {
1492
+ const key = String(interest.key || '').trim();
1493
+ const op = String(interest.op || 'equals').trim();
1494
+ const expected = interest.value;
1495
+ const occType = String(interest.occurrenceType || 'predominantly').trim();
1496
+ const pct = Number(interest.occurrencePercentage ?? 0);
1497
+ const threshold = Number.isFinite(pct) ? Math.max(0, Math.min(100, pct)) / 100 : 0;
1498
+ if (rows.length === 0)
1499
+ return false;
1500
+ let total = 0;
1501
+ let match = 0;
1502
+ const counts = new Map();
1503
+ for (const row of rows) {
1504
+ total++;
1505
+ const rawVal = getEventPropertyValue(row?.event_data, key);
1506
+ const strVal = rawVal === null || rawVal === undefined ? '' : String(rawVal);
1507
+ counts.set(strVal, (counts.get(strVal) || 0) + 1);
1508
+ if (matchesInterest(rawVal, op === 'contains' ? 'contains' : op, expected))
1509
+ match++;
1510
+ }
1511
+ if (occType === 'at_least') {
1512
+ return match > 0 && match / total >= threshold;
1513
+ }
1514
+ let maxOther = 0;
1515
+ counts.forEach((cnt, valStr) => {
1516
+ if (!matchesInterest(valStr, op, expected)) {
1517
+ if (cnt > maxOther)
1518
+ maxOther = cnt;
1519
+ }
1520
+ });
1521
+ return match > 0 && match >= maxOther;
1522
+ }
818
1523
  if (rule.frequency && rule.frequency.value != null) {
819
- const { op, value, type = 'count', field = 'value' } = rule.frequency;
1524
+ const { op, value, value2, type = 'count', field = 'value' } = rule.frequency;
820
1525
  let aggregatedValue = 0;
1526
+ let denom = 0;
821
1527
  if (type === 'count') {
822
1528
  aggregatedValue = rows.length;
823
1529
  }
824
1530
  else {
825
1531
  for (const row of rows) {
826
1532
  const eventData = row.event_data || {};
827
- let numVal = 0;
828
- if (eventData[field] !== undefined)
829
- numVal = Number(eventData[field]);
830
- else if (eventData['value'] !== undefined)
831
- numVal = Number(eventData['value']);
832
- else if (eventData['amount'] !== undefined)
833
- numVal = Number(eventData['amount']);
834
- if (!Number.isNaN(numVal))
1533
+ const raw = getEventPropertyValue(eventData, field) ??
1534
+ (eventData?.[field] !== undefined ? eventData[field] : undefined) ??
1535
+ eventData?.value ??
1536
+ eventData?.amount;
1537
+ const numVal = Number(raw);
1538
+ if (Number.isFinite(numVal)) {
835
1539
  aggregatedValue += numVal;
1540
+ denom += 1;
1541
+ }
836
1542
  }
837
1543
  if (type === 'avg')
838
- aggregatedValue = rows.length > 0 ? aggregatedValue / rows.length : 0;
1544
+ aggregatedValue = denom > 0 ? aggregatedValue / denom : 0;
1545
+ }
1546
+ if (op === 'between') {
1547
+ const a = Number(value);
1548
+ const b = Number(value2);
1549
+ if (!Number.isFinite(a) || !Number.isFinite(b))
1550
+ return false;
1551
+ if (a > b)
1552
+ return false;
1553
+ return aggregatedValue >= a && aggregatedValue <= b;
839
1554
  }
840
1555
  return ((op === '>=' && aggregatedValue >= value) ||
841
1556
  (op === '>' && aggregatedValue > value) ||