@startsimpli/funnels 0.4.13 → 0.4.15

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.
Files changed (37) hide show
  1. package/README.md +326 -281
  2. package/package.json +24 -16
  3. package/src/api/client.paths.test.ts +175 -0
  4. package/src/api/client.ts +78 -19
  5. package/src/api/http-adapter.test.ts +68 -0
  6. package/src/api/http-adapter.ts +48 -0
  7. package/src/api/index.ts +16 -0
  8. package/src/api/paths.test.ts +138 -0
  9. package/src/api/paths.ts +192 -0
  10. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  13. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  15. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  17. package/src/components/FilterRuleEditor/constants.ts +12 -0
  18. package/src/components/FunnelPreview/example.tsx +15 -15
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  22. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  23. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  24. package/src/core/evaluator.example.ts +23 -23
  25. package/src/core/evaluator.test.ts +4 -1
  26. package/src/core/evaluator.ts +11 -5
  27. package/src/core/operators.ts +35 -0
  28. package/src/page/FunnelsPage.test.tsx +383 -0
  29. package/src/page/FunnelsPage.tsx +739 -0
  30. package/src/page/index.ts +24 -0
  31. package/src/page/types.ts +102 -0
  32. package/src/store/create-funnel-store.ts +1 -0
  33. package/src/stories/demo-data/investors.ts +2 -2
  34. package/src/stories/demo-data/leads.ts +2 -2
  35. package/src/stories/demo-data/recipes.ts +2 -2
  36. package/src/types/contract.test.ts +284 -0
  37. package/src/types/index.ts +339 -49
@@ -38,7 +38,14 @@
38
38
  * - Focus management when adding/removing rules
39
39
  */
40
40
 
41
- import { FilterRule, FieldDefinition, Operator } from '../../types';
41
+ import { FilterRule, FieldDefinitionInput, Operator } from '../../types';
42
+ import {
43
+ fieldKey,
44
+ fieldOperators,
45
+ fieldValueType,
46
+ normalizeFieldDefinition,
47
+ ruleField,
48
+ } from '../../types';
42
49
  import { FieldSelector } from './FieldSelector';
43
50
  import { OperatorSelector } from './OperatorSelector';
44
51
  import { NULL_VALUE_OPERATORS, MULTI_VALUE_OPERATORS } from './constants';
@@ -55,7 +62,12 @@ interface RuleRowProps {
55
62
  rule: FilterRule;
56
63
  onChange: (rule: FilterRule) => void;
57
64
  onRemove: () => void;
58
- fieldRegistry: FieldDefinition[];
65
+ /**
66
+ * Accepts the canonical /fields/ shape AND the hand-written
67
+ * {name,type,operators} registries that predate it — every read goes through
68
+ * the accessors (bd startsim-pxzlf).
69
+ */
70
+ fieldRegistry: FieldDefinitionInput[];
59
71
  className?: string;
60
72
  }
61
73
 
@@ -66,11 +78,27 @@ export function RuleRow({
66
78
  fieldRegistry,
67
79
  className = '',
68
80
  }: RuleRowProps) {
69
- // Find the selected field definition
70
- const selectedField = fieldRegistry.find((f) => f.name === rule.fieldPath);
81
+ // Which of the rule's two keys this registry can actually resolve.
82
+ //
83
+ // A rule the backend auto-migrated carries BOTH: the serializer's legacy
84
+ // branch sets `field` from the adapter and leaves `field_path` in place, and
85
+ // _create_rule_tree persists the whole validated dict. So a rule market saved
86
+ // comes back {field: 'contact.name', fieldPath: 'contact__name'} while its
87
+ // registry is still keyed 'contact__name'. Preferring `field` unconditionally
88
+ // would blank the picker on exactly those rows — and re-saving a blank row
89
+ // drops the rule. Try the canonical key first, fall back to the deprecated
90
+ // one, and only then give up (showing the canonical key, so the user sees
91
+ // which field the row names rather than an empty box).
92
+ const canonicalKey = rule.field || '';
93
+ const legacyKey = rule.fieldPath || '';
94
+ const entryFor = (key: string) =>
95
+ key ? fieldRegistry.find((f) => fieldKey(f) === key) : undefined;
96
+ const selectedRaw = entryFor(canonicalKey) ?? entryFor(legacyKey);
97
+ const selectedKey = selectedRaw ? fieldKey(selectedRaw) : ruleField(rule);
98
+ const selectedField = selectedRaw ? normalizeFieldDefinition(selectedRaw) : undefined;
71
99
 
72
100
  // Get valid operators for the selected field
73
- const availableOperators = selectedField?.operators || [];
101
+ const availableOperators = selectedField?.allowedOperators || [];
74
102
 
75
103
  // Determine if value input is needed
76
104
  const needsValue = rule.operator && !NULL_VALUE_OPERATORS.includes(rule.operator);
@@ -79,11 +107,28 @@ export function RuleRow({
79
107
 
80
108
  // Handle field change
81
109
  const handleFieldChange = (fieldName: string) => {
82
- const field = fieldRegistry.find((f) => f.name === fieldName);
110
+ const entry = fieldRegistry.find((f) => fieldKey(f) === fieldName);
111
+
112
+ // WHAT GOES ON THE WIRE depends on where the name came from.
113
+ //
114
+ // A registry entry carrying `key` came from GET …/funnels/fields/, so the
115
+ // name IS a resolver key and belongs in `field`. A hand-written entry
116
+ // carries only `name` — 'contact__name', 'tag:stage_focus:seed' — which the
117
+ // resolver registry does not know. Putting THAT in `field` would take the
118
+ // serializer's strict branch (`if attrs.get("field")`) and earn a 400;
119
+ // leaving `field` empty takes the legacy branch, which is what those names
120
+ // have always been translated by. `field: ''` is accepted on the wire:
121
+ // FunnelFilterRule.field is a blank=True CharField, so DRF gives it
122
+ // allow_blank=True, and '' is falsy in the branch that matters.
123
+ const isRegistryKey = Boolean(entry && entry.key);
124
+
83
125
  onChange({
84
126
  ...rule,
127
+ field: isRegistryKey ? fieldName : '',
128
+ // The deprecated mirror carries the name either way: it is what the
129
+ // legacy branch reads, and a canonical rule ignores it.
85
130
  fieldPath: fieldName,
86
- operator: field?.operators[0] || ('' as Operator),
131
+ operator: (fieldOperators(entry ?? {})[0] as Operator) || ('' as Operator),
87
132
  value: null,
88
133
  });
89
134
  };
@@ -110,7 +155,8 @@ export function RuleRow({
110
155
  if (!needsValue) return null;
111
156
  if (!selectedField) return null;
112
157
 
113
- const { type, constraints } = selectedField;
158
+ const { constraints } = selectedField;
159
+ const type = fieldValueType(selectedField);
114
160
 
115
161
  // Multi-value operators
116
162
  if (needsMultiValue) {
@@ -135,6 +181,23 @@ export function RuleRow({
135
181
 
136
182
  // Single-value operators
137
183
  switch (type) {
184
+ case 'enum':
185
+ if (constraints?.choices) {
186
+ return (
187
+ <ChoiceValueInput
188
+ value={rule.value || ''}
189
+ onChange={handleValueChange}
190
+ choices={constraints.choices}
191
+ />
192
+ );
193
+ }
194
+ return (
195
+ <TextValueInput
196
+ value={rule.value || ''}
197
+ onChange={handleValueChange}
198
+ />
199
+ );
200
+
138
201
  case 'string':
139
202
  if (constraints?.choices && rule.operator === 'eq') {
140
203
  return (
@@ -228,7 +291,7 @@ export function RuleRow({
228
291
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 pr-8">
229
292
  <FieldSelector
230
293
  fields={fieldRegistry}
231
- value={rule.fieldPath}
294
+ value={selectedKey}
232
295
  onChange={handleFieldChange}
233
296
  />
234
297
 
@@ -236,7 +299,7 @@ export function RuleRow({
236
299
  operators={availableOperators}
237
300
  value={rule.operator || ''}
238
301
  onChange={handleOperatorChange}
239
- disabled={!rule.fieldPath}
302
+ disabled={!selectedKey}
240
303
  />
241
304
 
242
305
  {needsValue && renderValueInput()}
@@ -16,6 +16,7 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
16
16
  lt: 'less than',
17
17
  gte: 'greater or equal',
18
18
  lte: 'less or equal',
19
+ between: 'is between',
19
20
 
20
21
  // String operations
21
22
  contains: 'contains',
@@ -30,6 +31,10 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
30
31
  has_any: 'has any of',
31
32
  has_all: 'has all of',
32
33
 
34
+ // Presence
35
+ exists: 'is set',
36
+ not_exists: 'is not set',
37
+
33
38
  // Null checks
34
39
  isnull: 'is empty',
35
40
  isnotnull: 'is not empty',
@@ -47,6 +52,8 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
47
52
  * Operators that don't require a value input
48
53
  */
49
54
  export const NULL_VALUE_OPERATORS: Operator[] = [
55
+ 'exists',
56
+ 'not_exists',
50
57
  'isnull',
51
58
  'isnotnull',
52
59
  'is_true',
@@ -62,3 +69,8 @@ export const MULTI_VALUE_OPERATORS: Operator[] = [
62
69
  'has_any',
63
70
  'has_all',
64
71
  ];
72
+
73
+ /**
74
+ * Operators whose value is a [min, max] pair.
75
+ */
76
+ export const RANGE_OPERATORS: Operator[] = ['between'];
@@ -17,7 +17,7 @@ const investorFunnel: Funnel = {
17
17
  name: 'Investor Qualification',
18
18
  description: 'Filter investors by firm stage and check size',
19
19
  status: 'active',
20
- inputType: 'contacts',
20
+ entityType: 'investor',
21
21
  stages: [
22
22
  {
23
23
  id: 'stage-1',
@@ -25,9 +25,9 @@ const investorFunnel: Funnel = {
25
25
  name: 'Series A or Later',
26
26
  filterLogic: 'OR',
27
27
  rules: [
28
- { fieldPath: 'firm.stage', operator: 'eq', value: 'Series A' },
29
- { fieldPath: 'firm.stage', operator: 'eq', value: 'Series B' },
30
- { fieldPath: 'firm.stage', operator: 'eq', value: 'Series C' },
28
+ { field: 'firm.stage', operator: 'eq', value: 'Series A' },
29
+ { field: 'firm.stage', operator: 'eq', value: 'Series B' },
30
+ { field: 'firm.stage', operator: 'eq', value: 'Series C' },
31
31
  ],
32
32
  matchAction: 'continue',
33
33
  noMatchAction: 'exclude',
@@ -37,7 +37,7 @@ const investorFunnel: Funnel = {
37
37
  order: 1,
38
38
  name: 'High ICP Score',
39
39
  filterLogic: 'AND',
40
- rules: [{ fieldPath: 'icp_score', operator: 'gte', value: 80 }],
40
+ rules: [{ field: 'icp_score', operator: 'gte', value: 80 }],
41
41
  matchAction: 'output',
42
42
  noMatchAction: 'exclude',
43
43
  },
@@ -112,14 +112,14 @@ const recipeFunnel: Funnel = {
112
112
  name: 'Recipe Recommendations',
113
113
  description: 'Filter recipes by dietary restrictions and difficulty',
114
114
  status: 'active',
115
- inputType: 'any',
115
+ entityType: 'recipe',
116
116
  stages: [
117
117
  {
118
118
  id: 'dietary',
119
119
  order: 0,
120
120
  name: 'Vegetarian',
121
121
  filterLogic: 'AND',
122
- rules: [{ fieldPath: 'tags', operator: 'has_tag', value: 'vegetarian' }],
122
+ rules: [{ field: 'tags', operator: 'has_tag', value: 'vegetarian' }],
123
123
  matchAction: 'continue',
124
124
  noMatchAction: 'exclude',
125
125
  },
@@ -129,8 +129,8 @@ const recipeFunnel: Funnel = {
129
129
  name: 'Easy to Moderate',
130
130
  filterLogic: 'OR',
131
131
  rules: [
132
- { fieldPath: 'difficulty', operator: 'eq', value: 'easy' },
133
- { fieldPath: 'difficulty', operator: 'eq', value: 'moderate' },
132
+ { field: 'difficulty', operator: 'eq', value: 'easy' },
133
+ { field: 'difficulty', operator: 'eq', value: 'moderate' },
134
134
  ],
135
135
  matchAction: 'output',
136
136
  noMatchAction: 'exclude',
@@ -198,14 +198,14 @@ const leadFunnel: Funnel = {
198
198
  name: 'Qualified Leads',
199
199
  description: 'Score and filter leads based on company size and engagement',
200
200
  status: 'active',
201
- inputType: 'any',
201
+ entityType: 'lead',
202
202
  stages: [
203
203
  {
204
204
  id: 'company-size',
205
205
  order: 0,
206
206
  name: 'Mid-Market and Above',
207
207
  filterLogic: 'AND',
208
- rules: [{ fieldPath: 'company.employees', operator: 'gte', value: 50 }],
208
+ rules: [{ field: 'company.employees', operator: 'gte', value: 50 }],
209
209
  matchAction: 'continue',
210
210
  noMatchAction: 'exclude',
211
211
  },
@@ -214,7 +214,7 @@ const leadFunnel: Funnel = {
214
214
  order: 1,
215
215
  name: 'High Engagement',
216
216
  filterLogic: 'AND',
217
- rules: [{ fieldPath: 'engagement_score', operator: 'gte', value: 7 }],
217
+ rules: [{ field: 'engagement_score', operator: 'gte', value: 7 }],
218
218
  matchAction: 'tag_continue',
219
219
  noMatchAction: 'continue',
220
220
  matchTags: ['high-engagement'],
@@ -225,9 +225,9 @@ const leadFunnel: Funnel = {
225
225
  name: 'Target Industries',
226
226
  filterLogic: 'OR',
227
227
  rules: [
228
- { fieldPath: 'company.industry', operator: 'eq', value: 'Technology' },
229
- { fieldPath: 'company.industry', operator: 'eq', value: 'SaaS' },
230
- { fieldPath: 'company.industry', operator: 'eq', value: 'FinTech' },
228
+ { field: 'company.industry', operator: 'eq', value: 'Technology' },
229
+ { field: 'company.industry', operator: 'eq', value: 'SaaS' },
230
+ { field: 'company.industry', operator: 'eq', value: 'FinTech' },
231
231
  ],
232
232
  matchAction: 'output',
233
233
  noMatchAction: 'exclude',
@@ -5,11 +5,11 @@
5
5
  import type { Meta, StoryObj } from '@storybook/react';
6
6
  import { useState } from 'react';
7
7
  import { FunnelStageBuilder } from './FunnelStageBuilder';
8
- import { Funnel, FieldDefinition } from '../../types';
8
+ import { Funnel, FieldDefinitionInput } from '../../types';
9
9
  import './FunnelStageBuilder.css';
10
10
 
11
11
  // Sample field registry
12
- const sampleFieldRegistry: FieldDefinition[] = [
12
+ const sampleFieldRegistry: FieldDefinitionInput[] = [
13
13
  // Firm fields
14
14
  {
15
15
  name: 'firm.name',
@@ -5,10 +5,10 @@
5
5
  import { describe, it, expect, vi } from 'vitest';
6
6
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
7
7
  import { FunnelStageBuilder } from './FunnelStageBuilder';
8
- import { Funnel, FieldDefinition } from '../../types';
8
+ import { Funnel, FieldDefinitionInput } from '../../types';
9
9
 
10
10
  // Mock field registry
11
- const mockFieldRegistry: FieldDefinition[] = [
11
+ const mockFieldRegistry: FieldDefinitionInput[] = [
12
12
  {
13
13
  name: 'firm.stage',
14
14
  label: 'Firm Stage',
@@ -25,7 +25,7 @@ import {
25
25
  sortableKeyboardCoordinates,
26
26
  verticalListSortingStrategy,
27
27
  } from '@dnd-kit/sortable';
28
- import { Funnel, FunnelStage, FieldDefinition } from '../../types';
28
+ import { Funnel, FunnelStage, FieldDefinitionInput } from '../../types';
29
29
  import { StageCard } from './StageCard';
30
30
  import { AddStageButton } from './AddStageButton';
31
31
 
@@ -37,7 +37,7 @@ export interface FunnelStageBuilderProps {
37
37
  onUpdate: (funnel: Funnel) => void;
38
38
 
39
39
  /** Available fields for filter rules */
40
- fieldRegistry: FieldDefinition[];
40
+ fieldRegistry: FieldDefinitionInput[];
41
41
 
42
42
  /** Optional CSS class */
43
43
  className?: string;
@@ -12,7 +12,7 @@
12
12
  import React from 'react';
13
13
  import { useSortable } from '@dnd-kit/sortable';
14
14
  import { CSS } from '@dnd-kit/utilities';
15
- import { FunnelStage, FieldDefinition } from '../../types';
15
+ import { FunnelStage, FieldDefinitionInput } from '../../types';
16
16
  import { StageForm } from './StageForm';
17
17
 
18
18
  export interface StageCardProps {
@@ -32,7 +32,7 @@ export interface StageCardProps {
32
32
  onRemove: () => void;
33
33
 
34
34
  /** Available fields */
35
- fieldRegistry: FieldDefinition[];
35
+ fieldRegistry: FieldDefinitionInput[];
36
36
 
37
37
  /** Validation error */
38
38
  error?: string;
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useCallback, useState } from 'react';
14
- import { FunnelStage, FieldDefinition, FilterLogic, MatchAction, NoMatchAction, FilterRule } from '../../types';
14
+ import { FunnelStage, FieldDefinitionInput, FilterLogic, MatchAction, NoMatchAction, FilterRule } from '../../types';
15
15
  import { StageActions } from './StageActions';
16
16
  import { TagInput } from './TagInput';
17
17
  import { FilterRuleEditor } from '../FilterRuleEditor/FilterRuleEditor';
@@ -24,7 +24,7 @@ export interface StageFormProps {
24
24
  onUpdate: (stage: FunnelStage) => void;
25
25
 
26
26
  /** Available fields */
27
- fieldRegistry: FieldDefinition[];
27
+ fieldRegistry: FieldDefinitionInput[];
28
28
  }
29
29
 
30
30
  /**
@@ -72,7 +72,7 @@ const investors: Investor[] = [
72
72
  // Example 1a: Single rule evaluation
73
73
  console.log('=== Example 1a: Single Rule ===');
74
74
  const rule1: FilterRule = {
75
- fieldPath: 'firm.aum',
75
+ field: 'firm.aum',
76
76
  operator: 'gte',
77
77
  value: 100000000,
78
78
  };
@@ -85,9 +85,9 @@ investors.forEach(investor => {
85
85
  // Example 1b: Multiple rules with AND logic
86
86
  console.log('\n=== Example 1b: Multiple Rules (AND) ===');
87
87
  const rules1: FilterRule[] = [
88
- { fieldPath: 'firm.stage', operator: 'in', value: ['Series A', 'Series B'] },
89
- { fieldPath: 'firm.aum', operator: 'gte', value: 100000000 },
90
- { fieldPath: 'tags', operator: 'has_tag', value: 'qualified' },
88
+ { field: 'firm.stage', operator: 'in', value: ['Series A', 'Series B'] },
89
+ { field: 'firm.aum', operator: 'gte', value: 100000000 },
90
+ { field: 'tags', operator: 'has_tag', value: 'qualified' },
91
91
  ];
92
92
 
93
93
  const qualified = filterEntities(investors, rules1, 'AND');
@@ -151,7 +151,7 @@ const recipes: Recipe[] = [
151
151
  // Example 2a: Filter by dietary restrictions
152
152
  console.log('\n=== Example 2: Recipe Filtering ===');
153
153
  const veganRules: FilterRule[] = [
154
- { fieldPath: 'dietary', operator: 'has_tag', value: 'vegan' },
154
+ { field: 'dietary', operator: 'has_tag', value: 'vegan' },
155
155
  ];
156
156
 
157
157
  const veganRecipes = filterEntities(recipes, veganRules);
@@ -159,9 +159,9 @@ console.log('Vegan recipes:', veganRecipes.map(r => r.name));
159
159
 
160
160
  // Example 2b: Filter by multiple criteria (quick meals)
161
161
  const quickMealRules: FilterRule[] = [
162
- { fieldPath: 'prep_time_minutes', operator: 'lte', value: 30 },
163
- { fieldPath: 'difficulty', operator: 'in', value: ['easy', 'medium'] },
164
- { fieldPath: 'rating', operator: 'gte', value: 4.0 },
162
+ { field: 'prep_time_minutes', operator: 'lte', value: 30 },
163
+ { field: 'difficulty', operator: 'in', value: ['easy', 'medium'] },
164
+ { field: 'rating', operator: 'gte', value: 4.0 },
165
165
  ];
166
166
 
167
167
  const quickMeals = filterEntities(recipes, quickMealRules, 'AND');
@@ -222,9 +222,9 @@ const repos: GitHubRepo[] = [
222
222
  // Example 3a: Find popular active repos
223
223
  console.log('\n=== Example 3: GitHub Repos ===');
224
224
  const popularRepoRules: FilterRule[] = [
225
- { fieldPath: 'stars', operator: 'gte', value: 100 },
226
- { fieldPath: 'is_archived', operator: 'is_false', value: null },
227
- { fieldPath: 'last_updated', operator: 'gte', value: new Date('2024-01-01') },
225
+ { field: 'stars', operator: 'gte', value: 100 },
226
+ { field: 'is_archived', operator: 'is_false', value: null },
227
+ { field: 'last_updated', operator: 'gte', value: new Date('2024-01-01') },
228
228
  ];
229
229
 
230
230
  const popularRepos = filterEntities(repos, popularRepoRules, 'AND');
@@ -232,8 +232,8 @@ console.log('Popular active repos:', popularRepos.map(r => r.name));
232
232
 
233
233
  // Example 3b: Find TypeScript projects with React
234
234
  const tsReactRules: FilterRule[] = [
235
- { fieldPath: 'language', operator: 'eq', value: 'TypeScript' },
236
- { fieldPath: 'topics', operator: 'has_tag', value: 'react' },
235
+ { field: 'language', operator: 'eq', value: 'TypeScript' },
236
+ { field: 'topics', operator: 'has_tag', value: 'react' },
237
237
  ];
238
238
 
239
239
  const tsReactRepos = filterEntities(repos, tsReactRules, 'AND');
@@ -247,7 +247,7 @@ console.log('\n=== Example 4: Advanced Patterns ===');
247
247
 
248
248
  // Pattern 1: Negation
249
249
  const notArchivedRule: FilterRule = {
250
- fieldPath: 'is_archived',
250
+ field: 'is_archived',
251
251
  operator: 'is_true',
252
252
  value: null,
253
253
  negate: true, // Negate the result
@@ -258,7 +258,7 @@ console.log('Active repos (using negate):', activeRepos.map(r => r.name));
258
258
 
259
259
  // Pattern 2: Complex string matching
260
260
  const emailDomainRule: FilterRule = {
261
- fieldPath: 'email',
261
+ field: 'email',
262
262
  operator: 'matches',
263
263
  value: '@acme\\.vc$', // Matches emails ending with @acme.vc
264
264
  };
@@ -268,8 +268,8 @@ console.log('Acme VC investors:', acmeInvestors.map(i => i.name));
268
268
 
269
269
  // Pattern 3: OR logic (match any condition)
270
270
  const flexibleStageRules: FilterRule[] = [
271
- { fieldPath: 'firm.stage', operator: 'eq', value: 'Series A' },
272
- { fieldPath: 'firm.aum', operator: 'gte', value: 500000000 }, // OR very large fund
271
+ { field: 'firm.stage', operator: 'eq', value: 'Series A' },
272
+ { field: 'firm.aum', operator: 'gte', value: 500000000 }, // OR very large fund
273
273
  ];
274
274
 
275
275
  const flexibleMatch = filterEntities(investors, flexibleStageRules, 'OR');
@@ -286,7 +286,7 @@ investorsWithoutEmail.push({
286
286
  });
287
287
 
288
288
  const hasEmailRule: FilterRule = {
289
- fieldPath: 'email',
289
+ field: 'email',
290
290
  operator: 'isnotnull',
291
291
  value: null,
292
292
  };
@@ -296,7 +296,7 @@ console.log('Investors with email:', withEmail.map(i => i.name));
296
296
 
297
297
  // Pattern 5: Array operations
298
298
  const multiGeographyRule: FilterRule = {
299
- fieldPath: 'firm.geography',
299
+ field: 'firm.geography',
300
300
  operator: 'has_all',
301
301
  value: ['US', 'Europe'],
302
302
  };
@@ -329,22 +329,22 @@ function simulateFunnel<T>(entities: T[], stages: Array<{ name: string; rules: F
329
329
  const investorFunnelStages = [
330
330
  {
331
331
  name: 'Active & Not Archived',
332
- rules: [{ fieldPath: 'tags', operator: 'has_tag', value: 'active' } as FilterRule],
332
+ rules: [{ field: 'tags', operator: 'has_tag', value: 'active' } as FilterRule],
333
333
  logic: 'AND' as const,
334
334
  },
335
335
  {
336
336
  name: 'Series A+ Stage',
337
- rules: [{ fieldPath: 'firm.stage', operator: 'in', value: ['Series A', 'Series B', 'Series C'] } as FilterRule],
337
+ rules: [{ field: 'firm.stage', operator: 'in', value: ['Series A', 'Series B', 'Series C'] } as FilterRule],
338
338
  logic: 'AND' as const,
339
339
  },
340
340
  {
341
341
  name: 'Minimum AUM $100M',
342
- rules: [{ fieldPath: 'firm.aum', operator: 'gte', value: 100000000 } as FilterRule],
342
+ rules: [{ field: 'firm.aum', operator: 'gte', value: 100000000 } as FilterRule],
343
343
  logic: 'AND' as const,
344
344
  },
345
345
  {
346
346
  name: 'Qualified Tag',
347
- rules: [{ fieldPath: 'tags', operator: 'has_tag', value: 'qualified' } as FilterRule],
347
+ rules: [{ field: 'tags', operator: 'has_tag', value: 'qualified' } as FilterRule],
348
348
  logic: 'AND' as const,
349
349
  },
350
350
  ];
@@ -544,10 +544,13 @@ describe('Multiple Rules', () => {
544
544
 
545
545
  describe('Detailed Results', () => {
546
546
  it('should return detailed rule result', () => {
547
- const rule: FilterRule = { fieldPath: 'firm.stage', operator: 'eq', value: 'Series A' };
547
+ const rule: FilterRule = { field: 'firm.stage', operator: 'eq', value: 'Series A' };
548
548
  const result = evaluateRuleWithResult(sampleEntity, rule);
549
549
 
550
550
  expect(result).toEqual({
551
+ // `field` is the canonical key; `fieldPath` mirrors it for readers
552
+ // written before the one-contract change (bd startsim-pxzlf).
553
+ field: 'firm.stage',
551
554
  fieldPath: 'firm.stage',
552
555
  operator: 'eq',
553
556
  value: 'Series A',
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { FilterRule, RuleResult } from '../types';
14
+ import { ruleField } from '../types';
14
15
  import { resolveField } from './field-resolver';
15
16
  import { applyOperator } from './operators';
16
17
 
@@ -40,8 +41,10 @@ import { applyOperator } from './operators';
40
41
  * ```
41
42
  */
42
43
  export function evaluateRule<T>(entity: T, rule: FilterRule): boolean {
43
- // Resolve field value from entity
44
- const actualValue = resolveField(entity, rule.fieldPath);
44
+ // Resolve field value from entity. `ruleField` reads the canonical `field`
45
+ // and falls back to the deprecated `fieldPath`, so a rule persisted under
46
+ // either shape evaluates the same.
47
+ const actualValue = resolveField(entity, ruleField(rule));
45
48
 
46
49
  // Apply operator
47
50
  const result = applyOperator(rule.operator, actualValue, rule.value);
@@ -77,9 +80,10 @@ export function evaluateRuleWithResult<T>(
77
80
  entity: T,
78
81
  rule: FilterRule
79
82
  ): RuleResult {
83
+ const field = ruleField(rule);
80
84
  try {
81
85
  // Resolve field value
82
- const actualValue = resolveField(entity, rule.fieldPath);
86
+ const actualValue = resolveField(entity, field);
83
87
 
84
88
  // Apply operator
85
89
  const operatorResult = applyOperator(rule.operator, actualValue, rule.value);
@@ -88,7 +92,8 @@ export function evaluateRuleWithResult<T>(
88
92
  const matched = rule.negate ? !operatorResult : operatorResult;
89
93
 
90
94
  return {
91
- fieldPath: rule.fieldPath,
95
+ field,
96
+ fieldPath: field,
92
97
  operator: rule.operator,
93
98
  value: rule.value,
94
99
  actualValue: actualValue,
@@ -97,7 +102,8 @@ export function evaluateRuleWithResult<T>(
97
102
  } catch (error) {
98
103
  // Handle evaluation errors gracefully
99
104
  return {
100
- fieldPath: rule.fieldPath,
105
+ field,
106
+ fieldPath: field,
101
107
  operator: rule.operator,
102
108
  value: rule.value,
103
109
  actualValue: undefined,
@@ -46,6 +46,28 @@ export function applyOperator(
46
46
  case 'lte':
47
47
  return compareValues(actual, expected, (a, e) => a <= e);
48
48
 
49
+ // Inclusive range. The backend's ValueShape.TUPLE2: value is [min, max].
50
+ case 'between': {
51
+ if (!Array.isArray(expected) || expected.length !== 2) return false;
52
+ const [min, max] = expected;
53
+ return (
54
+ compareValues(actual, min, (a, e) => a >= e) &&
55
+ compareValues(actual, max, (a, e) => a <= e)
56
+ );
57
+ }
58
+
59
+ // ========================================================================
60
+ // Presence — the backend spelling. A tag category with no value, an
61
+ // attribute that is simply set. Absent, null and '' all count as absent,
62
+ // because a tag row that does not exist and an empty one mean the same
63
+ // thing to a person building a funnel.
64
+ // ========================================================================
65
+ case 'exists':
66
+ return isPresent(actual);
67
+
68
+ case 'not_exists':
69
+ return !isPresent(actual);
70
+
49
71
  // ========================================================================
50
72
  // String operations (case-insensitive)
51
73
  // ========================================================================
@@ -148,6 +170,19 @@ export function applyOperator(
148
170
  }
149
171
  }
150
172
 
173
+ /**
174
+ * Whether a resolved value counts as present for exists / not_exists.
175
+ *
176
+ * An empty array is absent: 'tag.stage_focus' resolving to [] means the entity
177
+ * carries no tag in that category, which is exactly what not_exists asks about.
178
+ */
179
+ function isPresent(actual: any): boolean {
180
+ if (actual === null || actual === undefined) return false;
181
+ if (typeof actual === 'string') return actual.length > 0;
182
+ if (Array.isArray(actual)) return actual.length > 0;
183
+ return true;
184
+ }
185
+
151
186
  /**
152
187
  * Compare values with type normalization
153
188
  *