@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;AAkHD,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;IAgoBjB,kCAAkC,CACtC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,GAAG,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC;IAwenB,OAAO,CAAC,IAAI;CAIb"}
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 ruleFilters = Array.isArray(rule.filters) ? rule.filters : [];
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
- if (eventData[field] !== undefined)
483
- numVal = Number(eventData[field]);
484
- else if (eventData['value'] !== undefined)
485
- numVal = Number(eventData['value']);
486
- else if (eventData['amount'] !== undefined)
487
- numVal = Number(eventData['amount']);
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
- let eventCount = 0;
498
- for (const row of rows) {
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 ruleFilters = Array.isArray(rule.filters) ? rule.filters : [];
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
- let numVal = 0;
1060
- if (eventData[field] !== undefined)
1061
- numVal = Number(eventData[field]);
1062
- else if (eventData['value'] !== undefined)
1063
- numVal = Number(eventData['value']);
1064
- else if (eventData['amount'] !== undefined)
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 = 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;
1071
1554
  }
1072
1555
  return ((op === '>=' && aggregatedValue >= value) ||
1073
1556
  (op === '>' && aggregatedValue > value) ||