@reachy/audience-module 1.0.13 → 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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"V2AudienceEngine.d.ts","sourceRoot":"","sources":["../../src/engine/V2AudienceEngine.ts"],"names":[],"mappings":"AAGA,KAAK,MAAM,GAAG;IACZ,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC7B,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC9B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAChC,CAAA;
|
|
1
|
+
{"version":3,"file":"V2AudienceEngine.d.ts","sourceRoot":"","sources":["../../src/engine/V2AudienceEngine.ts"],"names":[],"mappings":"AAGA,KAAK,MAAM,GAAG;IACZ,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC7B,IAAI,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC9B,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;CAChC,CAAA;AAyPD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAK;IACrB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,MAAM,CAAQ;gBAEV,MAAM,EAAE;QAAE,cAAc,EAAE,GAAG,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;IAMvE,iCAAiC,CACrC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,GAAG,GACf,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IA+zBjB,kCAAkC,CACtC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,GAAG,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC;IA2mBnB,OAAO,CAAC,IAAI;CAIb"}
|
|
@@ -31,6 +31,100 @@ const getEventPropertyValue = (eventData, keyRaw) => {
|
|
|
31
31
|
return getByPath(ed?.session_data, key.replace(/^session_data\./, ''));
|
|
32
32
|
return getByPath(ed, key) ?? getByPath(ed?.custom_data, key);
|
|
33
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
|
+
};
|
|
34
128
|
const matchesInterest = (raw, op, expected) => {
|
|
35
129
|
if (raw === null || raw === undefined)
|
|
36
130
|
return false;
|
|
@@ -118,8 +212,9 @@ const matchesEventPropertyFilter = (raw, opRaw, expected) => {
|
|
|
118
212
|
}
|
|
119
213
|
return false;
|
|
120
214
|
};
|
|
121
|
-
const applyEventRuleFilters = (rows, filters) => {
|
|
215
|
+
const applyEventRuleFilters = (rows, filters, opts) => {
|
|
122
216
|
let out = rows;
|
|
217
|
+
const tz = String(opts?.timezone || '').trim();
|
|
123
218
|
for (const f of filters) {
|
|
124
219
|
const type = String(f?.type || '').trim();
|
|
125
220
|
if (type === 'event_property') {
|
|
@@ -130,6 +225,62 @@ const applyEventRuleFilters = (rows, filters) => {
|
|
|
130
225
|
const value = f?.value;
|
|
131
226
|
out = out.filter((row) => matchesEventPropertyFilter(getEventPropertyValue(row?.event_data, key), op, value));
|
|
132
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
|
+
}
|
|
133
284
|
}
|
|
134
285
|
return out;
|
|
135
286
|
};
|
|
@@ -141,6 +292,21 @@ class V2AudienceEngine {
|
|
|
141
292
|
}
|
|
142
293
|
async getContactIdsByAudienceCriteriaV2(organizationId, projectId, criteriaRaw) {
|
|
143
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
|
+
}
|
|
144
310
|
if ((!Array.isArray(criteria.groups) || criteria.groups.length === 0) &&
|
|
145
311
|
(Array.isArray(criteria.filters) || Array.isArray(criteria.conditions))) {
|
|
146
312
|
criteria = coerceCriteriaToGroups(criteria);
|
|
@@ -163,12 +329,15 @@ class V2AudienceEngine {
|
|
|
163
329
|
.eq('project_id', projectId);
|
|
164
330
|
const allContactIds = new Set((allContacts || []).map((c) => c.id));
|
|
165
331
|
const reachyIdToContactId = new Map();
|
|
332
|
+
const contactIdToReachyId = new Map();
|
|
166
333
|
const emailToContactId = new Map();
|
|
167
334
|
for (const c of allContacts || []) {
|
|
168
335
|
const rid = c?.reachy_id ? String(c.reachy_id).trim() : '';
|
|
169
336
|
const cid = c?.id ? String(c.id).trim() : '';
|
|
170
|
-
if (rid && cid)
|
|
337
|
+
if (rid && cid) {
|
|
171
338
|
reachyIdToContactId.set(rid, cid);
|
|
339
|
+
contactIdToReachyId.set(cid, rid);
|
|
340
|
+
}
|
|
172
341
|
const email = c?.email ? String(c.email).trim().toLowerCase() : '';
|
|
173
342
|
if (email && cid)
|
|
174
343
|
emailToContactId.set(email, cid);
|
|
@@ -412,9 +581,172 @@ class V2AudienceEngine {
|
|
|
412
581
|
if (error)
|
|
413
582
|
return new Set();
|
|
414
583
|
let rows = (data || []);
|
|
415
|
-
const
|
|
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
|
+
});
|
|
416
591
|
if (ruleFilters.length > 0) {
|
|
417
|
-
rows = applyEventRuleFilters(rows, ruleFilters);
|
|
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
|
+
});
|
|
418
750
|
}
|
|
419
751
|
if (interest && interest.key) {
|
|
420
752
|
const key = String(interest.key || '').trim();
|
|
@@ -467,8 +799,9 @@ class V2AudienceEngine {
|
|
|
467
799
|
return res;
|
|
468
800
|
}
|
|
469
801
|
if (rule.frequency && rule.frequency.value != null) {
|
|
470
|
-
const { op, value, type = 'count', field = 'value' } = rule.frequency;
|
|
802
|
+
const { op, value, value2, type = 'count', field = 'value' } = rule.frequency;
|
|
471
803
|
const counts = new Map();
|
|
804
|
+
const denoms = new Map();
|
|
472
805
|
for (const row of rows) {
|
|
473
806
|
const id = resolveEventContactId(row);
|
|
474
807
|
if (!id)
|
|
@@ -477,16 +810,16 @@ class V2AudienceEngine {
|
|
|
477
810
|
counts.set(id, (counts.get(id) || 0) + 1);
|
|
478
811
|
}
|
|
479
812
|
else if (type === 'sum' || type === 'avg') {
|
|
480
|
-
let numVal = 0;
|
|
481
813
|
const eventData = row.event_data || {};
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
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)) {
|
|
489
820
|
counts.set(id, (counts.get(id) || 0) + numVal);
|
|
821
|
+
if (type === 'avg')
|
|
822
|
+
denoms.set(id, (denoms.get(id) || 0) + 1);
|
|
490
823
|
}
|
|
491
824
|
}
|
|
492
825
|
}
|
|
@@ -494,20 +827,27 @@ class V2AudienceEngine {
|
|
|
494
827
|
counts.forEach((aggregatedValue, id) => {
|
|
495
828
|
let finalValue = aggregatedValue;
|
|
496
829
|
if (type === 'avg') {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const rid = resolveEventContactId(row);
|
|
500
|
-
if (rid === id)
|
|
501
|
-
eventCount++;
|
|
502
|
-
}
|
|
503
|
-
finalValue = eventCount > 0 ? aggregatedValue / eventCount : 0;
|
|
830
|
+
const denom = denoms.get(id) || 0;
|
|
831
|
+
finalValue = denom > 0 ? aggregatedValue / denom : 0;
|
|
504
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
|
+
})();
|
|
505
844
|
if ((op === '>=' && finalValue >= value) ||
|
|
506
845
|
(op === '>' && finalValue > value) ||
|
|
507
846
|
(op === '=' && finalValue === value) ||
|
|
508
847
|
(op === '!=' && finalValue !== value) ||
|
|
509
848
|
(op === '<=' && finalValue <= value) ||
|
|
510
|
-
(op === '<' && finalValue < value)
|
|
849
|
+
(op === '<' && finalValue < value) ||
|
|
850
|
+
betweenOk) {
|
|
511
851
|
res.add(id);
|
|
512
852
|
}
|
|
513
853
|
});
|
|
@@ -797,6 +1137,21 @@ class V2AudienceEngine {
|
|
|
797
1137
|
return false;
|
|
798
1138
|
}
|
|
799
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
|
+
}
|
|
800
1155
|
const { data: contactRow } = await this.supabase
|
|
801
1156
|
.from('contacts')
|
|
802
1157
|
.select('id, reachy_id, email')
|
|
@@ -1011,9 +1366,127 @@ class V2AudienceEngine {
|
|
|
1011
1366
|
if (error)
|
|
1012
1367
|
return false;
|
|
1013
1368
|
let rows = (data || []);
|
|
1014
|
-
const
|
|
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
|
+
});
|
|
1015
1376
|
if (ruleFilters.length > 0) {
|
|
1016
|
-
rows = applyEventRuleFilters(rows, ruleFilters);
|
|
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
|
+
}
|
|
1017
1490
|
}
|
|
1018
1491
|
if (interest && interest.key) {
|
|
1019
1492
|
const key = String(interest.key || '').trim();
|
|
@@ -1048,26 +1521,36 @@ class V2AudienceEngine {
|
|
|
1048
1521
|
return match > 0 && match >= maxOther;
|
|
1049
1522
|
}
|
|
1050
1523
|
if (rule.frequency && rule.frequency.value != null) {
|
|
1051
|
-
const { op, value, type = 'count', field = 'value' } = rule.frequency;
|
|
1524
|
+
const { op, value, value2, type = 'count', field = 'value' } = rule.frequency;
|
|
1052
1525
|
let aggregatedValue = 0;
|
|
1526
|
+
let denom = 0;
|
|
1053
1527
|
if (type === 'count') {
|
|
1054
1528
|
aggregatedValue = rows.length;
|
|
1055
1529
|
}
|
|
1056
1530
|
else {
|
|
1057
1531
|
for (const row of rows) {
|
|
1058
1532
|
const eventData = row.event_data || {};
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
numVal = Number(eventData['amount']);
|
|
1066
|
-
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)) {
|
|
1067
1539
|
aggregatedValue += numVal;
|
|
1540
|
+
denom += 1;
|
|
1541
|
+
}
|
|
1068
1542
|
}
|
|
1069
1543
|
if (type === 'avg')
|
|
1070
|
-
aggregatedValue =
|
|
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;
|
|
1071
1554
|
}
|
|
1072
1555
|
return ((op === '>=' && aggregatedValue >= value) ||
|
|
1073
1556
|
(op === '>' && aggregatedValue > value) ||
|