@engine9/schemas 1.11.0 → 1.11.3

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.
@@ -60,8 +60,9 @@ export const emailEngagement = {
60
60
  type: 'object',
61
61
  properties: {
62
62
  pluginId: {
63
- title: 'Plugin ID',
64
- type: 'string'
63
+ title: 'Plugin',
64
+ type: 'string',
65
+ resolveOptionsUsing: 'plugin'
65
66
  },
66
67
  windowDays: {
67
68
  title: 'Window (days)',
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@engine9/schemas",
3
- "version": "1.11.0",
3
+ "version": "1.11.3",
4
4
  "type": "module",
5
5
  "description": "Engine9 standard schemas: shared tables, transforms, searches, and segments",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "node --test person/upsert.test.js person_address/normalize.test.js person_email/upsert.test.js person_hash/hash.test.js person_name/upsert.test.js person_phone/upsert.test.js stacks/stacks.test.js transaction/core/attribute.test.js"
8
+ "test": "node --test person/upsert.test.js person_address/normalize.test.js person_address/segmentDefinitionClauses.test.js person_email/upsert.test.js person_hash/hash.test.js person_name/upsert.test.js person_phone/upsert.test.js stacks/stacks.test.js timeline/segmentDefinitionClauses.test.js transaction/core/attribute.test.js transaction/core/segmentDefinitionClauses.test.js"
9
9
  },
10
10
  "engines": {
11
11
  "node": ">=18"
package/person/index.js CHANGED
@@ -22,9 +22,9 @@ const transforms = {
22
22
  appendName
23
23
  };
24
24
  const person = {
25
- title: 'Primary Info',
25
+ title: 'Ids',
26
26
  form: {
27
- title: 'Primary Info',
27
+ title: 'Ids',
28
28
  type: 'object',
29
29
  properties: {
30
30
  ids: {
@@ -2,6 +2,7 @@ import upsertPersonAddress from './transforms/inbound/upsert_tables.js';
2
2
  import normalize from './transforms/inbound/normalize.js';
3
3
  import appendAddress from './transforms/outbound/appendAddress.js';
4
4
  import schema from './schema.js';
5
+ import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
5
6
 
6
7
  const metadata = {
7
8
  name: '@engine9/schemas/person_address',
@@ -23,8 +24,10 @@ export const transforms = {
23
24
 
24
25
  export { metadata };
25
26
  export { schema };
27
+ export { segmentDefinitionClauses };
26
28
  export default {
27
29
  metadata,
28
30
  schema,
29
- transforms
31
+ transforms,
32
+ segmentDefinitionClauses
30
33
  };
@@ -0,0 +1,98 @@
1
+ import { normalizeCountry, normalizePostalCode, normalizeRegion } from './normalize.js';
2
+
3
+ function blank(value) {
4
+ return value == null || String(value).trim() === '';
5
+ }
6
+
7
+ /* Stored regions are normalized per country; without a country, accept US then Canadian names. */
8
+ function regionFor(region, country) {
9
+ if (country) return normalizeRegion(region, country);
10
+ const us = normalizeRegion(region, 'US');
11
+ const upper = String(region).toUpperCase().replace(/\./g, '').trim();
12
+ return us !== upper ? us : normalizeRegion(region, 'CA');
13
+ }
14
+
15
+ /* Without a country, read US ZIPs and Canadian postal codes by shape; leave anything else as typed. */
16
+ function postalCountryFor(postalCode, country) {
17
+ if (country) return country;
18
+ if (/^\d{5}(-?\d{4})?$/.test(postalCode)) return 'US';
19
+ if (/^[A-Z]\d[A-Z] ?\d[A-Z]\d$/.test(postalCode)) return 'CA';
20
+ return null;
21
+ }
22
+
23
+ function equals(column, value) {
24
+ return {
25
+ type: 'EQUALS',
26
+ values: [{ ref: { column } }, { value: { value } }]
27
+ };
28
+ }
29
+
30
+ export const addresses = {
31
+ title: 'Addresses',
32
+ description:
33
+ 'People with an address in a region (state / province) and/or postal code. One value per field; combine several with an or group.',
34
+ form: {
35
+ title: 'Addresses',
36
+ type: 'object',
37
+ properties: {
38
+ region: {
39
+ title: 'Region',
40
+ description: "State or province code or name, e.g. 'VA' or 'Virginia'",
41
+ type: 'string'
42
+ },
43
+ postalCode: {
44
+ title: 'Postal code',
45
+ description: "Exact postal code, or a LIKE pattern when it contains %, e.g. '220%'",
46
+ type: 'string'
47
+ },
48
+ country: {
49
+ title: 'Country',
50
+ description: "ISO country code or name, e.g. 'US'. Optional; narrows the match and how region and postal code are read.",
51
+ type: 'string'
52
+ }
53
+ },
54
+ required: []
55
+ },
56
+ optionsToEQL(options = {}) {
57
+ const { region, postalCode, country: countryOption } = options;
58
+ const conditions = [];
59
+ const text = [];
60
+ const country = blank(countryOption) ? null : normalizeCountry(countryOption);
61
+ if (!blank(region)) {
62
+ const value = regionFor(region, country);
63
+ conditions.push(equals('region', value));
64
+ text.push(`in ${value}`);
65
+ }
66
+ if (!blank(postalCode)) {
67
+ const raw = String(postalCode).trim().toUpperCase();
68
+ if (raw.includes('%')) {
69
+ conditions.push({
70
+ type: 'LIKE',
71
+ values: [{ ref: { column: 'postal_code' } }, { value: { value: raw } }]
72
+ });
73
+ text.push(`with postal code matching ${raw}`);
74
+ } else {
75
+ const value = normalizePostalCode(raw, postalCountryFor(raw, country));
76
+ conditions.push(equals('postal_code', value));
77
+ text.push(`with postal code ${value}`);
78
+ }
79
+ }
80
+ if (country) {
81
+ conditions.push(equals('country', country));
82
+ text.push(`in country ${country}`);
83
+ }
84
+ return {
85
+ text: ['Has an address', ...text].join(' '),
86
+ eql: {
87
+ table: 'person_address',
88
+ columns: ['person_id'],
89
+ conditions
90
+ }
91
+ };
92
+ }
93
+ };
94
+
95
+ export const segmentDefinitionClauses = { addresses };
96
+ export default {
97
+ segmentDefinitionClauses
98
+ };
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { addresses } from './segmentDefinitionClauses.js';
4
+
5
+ const valuesByColumn = (eql) =>
6
+ Object.fromEntries(eql.conditions.map((c) => [c.values[0].ref.column, [c.type, c.values[1].value.value]]));
7
+
8
+ test('region names normalize to the stored code', () => {
9
+ const { text, eql } = addresses.optionsToEQL({ region: 'Virginia' });
10
+ assert.equal(eql.table, 'person_address');
11
+ assert.deepEqual(eql.columns, ['person_id']);
12
+ assert.deepEqual(valuesByColumn(eql), { region: ['EQUALS', 'VA'] });
13
+ assert.equal(text, 'Has an address in VA');
14
+ });
15
+
16
+ test('Canadian province names normalize without a country', () => {
17
+ const { eql } = addresses.optionsToEQL({ region: 'Ontario' });
18
+ assert.deepEqual(valuesByColumn(eql), { region: ['EQUALS', 'ON'] });
19
+ });
20
+
21
+ test('postal codes normalize by shape, or match a LIKE pattern', () => {
22
+ assert.deepEqual(valuesByColumn(addresses.optionsToEQL({ postalCode: '22201-1234' }).eql), {
23
+ postal_code: ['EQUALS', '22201']
24
+ });
25
+ assert.deepEqual(valuesByColumn(addresses.optionsToEQL({ postalCode: 'k1a0b1' }).eql), {
26
+ postal_code: ['EQUALS', 'K1A 0B1']
27
+ });
28
+ assert.deepEqual(valuesByColumn(addresses.optionsToEQL({ postalCode: 'SW1A 1AA' }).eql), {
29
+ postal_code: ['EQUALS', 'SW1A 1AA']
30
+ });
31
+ assert.deepEqual(valuesByColumn(addresses.optionsToEQL({ postalCode: '220%' }).eql), {
32
+ postal_code: ['LIKE', '220%']
33
+ });
34
+ });
35
+
36
+ test('country narrows the match and drives region normalization', () => {
37
+ const { text, eql } = addresses.optionsToEQL({ region: 'virginia', country: 'United States' });
38
+ assert.deepEqual(valuesByColumn(eql), { region: ['EQUALS', 'VA'], country: ['EQUALS', 'US'] });
39
+ assert.equal(text, 'Has an address in VA in country US');
40
+ });
41
+
42
+ test('no options means any address', () => {
43
+ const { text, eql } = addresses.optionsToEQL({});
44
+ assert.deepEqual(eql.conditions, []);
45
+ assert.equal(text, 'Has an address');
46
+ });
@@ -15,15 +15,16 @@ const metadata = {
15
15
  }
16
16
  };
17
17
  const all = {
18
- title: 'Remote people',
19
- name: 'All remote people',
18
+ title: 'Remote Ids',
19
+ name: 'All remote ids',
20
20
  form: {
21
- title: 'Remote people',
21
+ title: 'Remote Ids',
22
22
  type: 'object',
23
23
  properties: {
24
24
  pluginId: {
25
- title: 'Plugin ID',
26
- type: 'string'
25
+ title: 'Plugin',
26
+ type: 'string',
27
+ resolveOptionsUsing: 'plugin'
27
28
  }
28
29
  },
29
30
  required: []
package/plugin/schema.js CHANGED
@@ -35,7 +35,7 @@ export const tables = [
35
35
  id: 'id_uuid',
36
36
  plugin_id: 'foreign_uuid',
37
37
  name: 'string',
38
- value: 'string',
38
+ value: 'text',
39
39
  created_at: 'created_at',
40
40
  modified_at: 'modified_at'
41
41
  },
@@ -6,8 +6,8 @@ export const segment = {
6
6
  properties: {
7
7
  segmentId: {
8
8
  title: 'Segment',
9
- description: '',
10
- type: 'string'
9
+ type: 'string',
10
+ resolveOptionsUsing: 'segment'
11
11
  }
12
12
  },
13
13
  required: []
@@ -8,8 +8,8 @@
8
8
  "@engine9/schemas/person",
9
9
  "@engine9/schemas/person_remote",
10
10
  "@engine9/schemas/segment",
11
- "@engine9/schemas/timeline",
12
11
  "@engine9/schemas/source_code",
12
+ "@engine9/schemas/timeline",
13
13
  "@engine9/schemas/transaction/core",
14
14
  "@engine9/schemas/person_hash"
15
15
  ],
@@ -12,6 +12,11 @@ test('standard stack includes plaintext contact plugins and does not exclude the
12
12
  assert.ok(standard.include.includes('@engine9/schemas/person_address'));
13
13
  assert.ok(standard.include.includes('@engine9/schemas/timeline'));
14
14
  assert.ok(standard.include.includes('@engine9/schemas/source_code'));
15
+ assert.ok(
16
+ standard.include.indexOf('@engine9/schemas/source_code') <
17
+ standard.include.indexOf('@engine9/schemas/timeline'),
18
+ 'source_code must install before timeline (timeline depends on it)'
19
+ );
15
20
  assert.ok(standard.include.includes('@engine9/schemas/transaction/core'));
16
21
  assert.ok(!standard.include.includes('@engine9/plugins/reports/people'));
17
22
  assert.deepEqual(standard.exclude, []);
@@ -12,8 +12,8 @@
12
12
  "@engine9/schemas/person_email",
13
13
  "@engine9/schemas/person_phone",
14
14
  "@engine9/schemas/person_address",
15
- "@engine9/schemas/timeline",
16
15
  "@engine9/schemas/source_code",
16
+ "@engine9/schemas/timeline",
17
17
  "@engine9/schemas/transaction/core",
18
18
  "@engine9/schemas/transaction/contact_details"
19
19
  ],
package/timeline/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import schema from './schema.js';
2
2
  import upsert from './transforms/inbound/upsert_tables.js';
3
+ import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
3
4
  const metadata = {
4
5
  name: '@engine9/schemas/timeline',
5
6
  dependencies: {
6
- '@engine9/schemas/person': '>=1.9.0'
7
+ '@engine9/schemas/person': '>=1.9.0',
8
+ '@engine9/schemas/source_code': '>=1.9.0'
7
9
  }
8
10
  };
9
11
  export const transforms = {
@@ -11,8 +13,10 @@ export const transforms = {
11
13
  };
12
14
  export { metadata };
13
15
  export { schema };
16
+ export { segmentDefinitionClauses };
14
17
  export default {
15
18
  metadata,
16
19
  schema,
17
- transforms
20
+ transforms,
21
+ segmentDefinitionClauses
18
22
  };
@@ -0,0 +1,56 @@
1
+ function sourceCodeCondition(sourceCode) {
2
+ const value = String(sourceCode);
3
+ const type = value.includes('%') ? 'LIKE' : 'EQUALS';
4
+ return {
5
+ type,
6
+ values: [{ ref: { column: 'source_code' } }, { value: { value } }]
7
+ };
8
+ }
9
+
10
+ /**
11
+ * People with at least one timeline entry whose source_code matches.
12
+ *
13
+ * Options:
14
+ * - sourceCode: exact code, or LIKE pattern when it contains %
15
+ */
16
+ export const sourceCode = {
17
+ title: 'Timeline source code',
18
+ description:
19
+ 'People with a timeline entry credited to a source code. Exact match, or a LIKE pattern when the value contains %.',
20
+ form: {
21
+ title: 'Timeline source code',
22
+ type: 'object',
23
+ properties: {
24
+ sourceCode: {
25
+ title: 'Source code',
26
+ description: 'Exact source code, or a LIKE pattern when it contains %',
27
+ type: 'string'
28
+ }
29
+ },
30
+ required: ['sourceCode']
31
+ },
32
+ optionsToEQL(options = {}) {
33
+ const { sourceCode: code } = options;
34
+ if (!code) throw new Error('sourceCode is required');
35
+ const matchLabel = String(code).includes('%') ? 'matching ' : '';
36
+ return {
37
+ text: `Has a timeline entry with source code ${matchLabel}${code}`,
38
+ eql: {
39
+ table: 'timeline',
40
+ columns: ['person_id'],
41
+ joins: [
42
+ {
43
+ table: 'source_code_dictionary',
44
+ join_eql: 'timeline.source_code_id=source_code_dictionary.source_code_id'
45
+ }
46
+ ],
47
+ conditions: [sourceCodeCondition(code)]
48
+ }
49
+ };
50
+ }
51
+ };
52
+
53
+ export const segmentDefinitionClauses = { sourceCode };
54
+ export default {
55
+ segmentDefinitionClauses
56
+ };
@@ -0,0 +1,37 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { sourceCode, segmentDefinitionClauses } from './segmentDefinitionClauses.js';
4
+
5
+ test('timeline sourceCode is the only segment definition clause', () => {
6
+ assert.deepEqual(Object.keys(segmentDefinitionClauses), ['sourceCode']);
7
+ });
8
+
9
+ test('timeline sourceCode requires sourceCode', () => {
10
+ assert.throws(() => sourceCode.optionsToEQL({}), /sourceCode/);
11
+ });
12
+
13
+ test('timeline sourceCode exact match joins the dictionary', () => {
14
+ const { text, eql } = sourceCode.optionsToEQL({ sourceCode: 'AU_TEST' });
15
+ assert.equal(text, 'Has a timeline entry with source code AU_TEST');
16
+ assert.equal(eql.table, 'timeline');
17
+ assert.deepEqual(eql.columns, ['person_id']);
18
+ assert.deepEqual(eql.joins, [
19
+ {
20
+ table: 'source_code_dictionary',
21
+ join_eql: 'timeline.source_code_id=source_code_dictionary.source_code_id'
22
+ }
23
+ ]);
24
+ assert.deepEqual(eql.conditions, [
25
+ {
26
+ type: 'EQUALS',
27
+ values: [{ ref: { column: 'source_code' } }, { value: { value: 'AU_TEST' } }]
28
+ }
29
+ ]);
30
+ });
31
+
32
+ test('timeline sourceCode uses LIKE when the value contains %', () => {
33
+ const { text, eql } = sourceCode.optionsToEQL({ sourceCode: 'AUTH_%' });
34
+ assert.equal(text, 'Has a timeline entry with source code matching AUTH_%');
35
+ assert.equal(eql.conditions[0].type, 'LIKE');
36
+ assert.equal(eql.conditions[0].values[1].value.value, 'AUTH_%');
37
+ });
@@ -55,8 +55,7 @@ The inbound `upsert` transform writes rows to `transaction`.
55
55
 
56
56
  ## Segment definition clauses
57
57
 
58
- - `all` selects people who have any transaction, optionally filtered by `plugin_id` and a `start` / `end` window on `ts` (ISO datetimes or relative expressions such as `-30d`).
59
- - `minimum` selects people who have a transaction of at least a given `amount`.
58
+ - `all` selects people who have any transaction, optionally filtered by `plugin_id`, a `start` / `end` window on `ts` (ISO datetimes or relative expressions such as `-30d`), and `minAmount` / `maxAmount` (inclusive) on `amount`. Every filter applies to the same transaction, so `{ start: '2026-01-01', minAmount: 100 }` is one gift of at least 100 this year.
60
59
 
61
60
  ## Metrics
62
61
 
@@ -1,5 +1,15 @@
1
1
  import { relativeDate, isValidDate } from '@engine9/input-tools';
2
2
 
3
+ function blank(value) {
4
+ return value == null || String(value).trim() === '';
5
+ }
6
+
7
+ function amountOption(name, value) {
8
+ const n = Number(value);
9
+ if (!Number.isFinite(n)) throw new Error(`Invalid ${name}: ${value}`);
10
+ return n;
11
+ }
12
+
3
13
  export const all = {
4
14
  title: 'Transactions',
5
15
  form: {
@@ -7,7 +17,9 @@ export const all = {
7
17
  type: 'object',
8
18
  properties: {
9
19
  plugin_id: {
10
- type: 'string'
20
+ title: 'Plugin',
21
+ type: 'string',
22
+ resolveOptionsUsing: 'plugin'
11
23
  },
12
24
  start: {
13
25
  title: 'Start',
@@ -20,12 +32,22 @@ export const all = {
20
32
  type: 'string',
21
33
  description:
22
34
  'Optional; only include rows with ts < end (exclusive upper bound). Parsed with relativeDate; supports ISO datetimes or relative expressions.'
35
+ },
36
+ minAmount: {
37
+ title: 'Minimum amount',
38
+ type: 'number',
39
+ description: 'Optional; only include rows with amount >= minAmount.'
40
+ },
41
+ maxAmount: {
42
+ title: 'Maximum amount',
43
+ type: 'number',
44
+ description: 'Optional; only include rows with amount <= maxAmount.'
23
45
  }
24
46
  },
25
47
  required: []
26
48
  },
27
49
  optionsToEQL: (options) => {
28
- const { plugin_id, start, end } = options;
50
+ const { plugin_id, start, end, minAmount, maxAmount } = options;
29
51
  const conditions = [];
30
52
  let text = 'Has any transactions';
31
53
  if (plugin_id) {
@@ -44,6 +66,16 @@ export const all = {
44
66
  conditions.push(`transaction.ts<'${e.toISOString()}'`);
45
67
  text += ` with ts < ${e.toISOString()}`;
46
68
  }
69
+ if (!blank(minAmount)) {
70
+ const min = amountOption('minAmount', minAmount);
71
+ conditions.push(`transaction.amount>=${min}`);
72
+ text += ` with amount >= ${min}`;
73
+ }
74
+ if (!blank(maxAmount)) {
75
+ const max = amountOption('maxAmount', maxAmount);
76
+ conditions.push(`transaction.amount<=${max}`);
77
+ text += ` with amount <= ${max}`;
78
+ }
47
79
  return {
48
80
  text,
49
81
  eql: {
@@ -60,28 +92,7 @@ export const all = {
60
92
  };
61
93
  }
62
94
  };
63
- export const minimum = {
64
- title: 'Minimum transaction amount',
65
- form: {
66
- title: 'Minimum transaction amount',
67
- type: 'object',
68
- properties: {
69
- amount: {
70
- type: 'number'
71
- },
72
- filterType: {
73
- type: 'string'
74
- }
75
- },
76
- required: []
77
- },
78
- optionsToEQL: (options) => ({
79
- table: 'transaction',
80
- columns: ['person_id'],
81
- conditions: [`amount>=${parseFloat(options.amount)}`]
82
- })
83
- };
84
- export const segmentDefinitionClauses = { all, minimum };
95
+ export const segmentDefinitionClauses = { all };
85
96
  export default {
86
97
  segmentDefinitionClauses
87
98
  };
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { all, segmentDefinitionClauses } from './segmentDefinitionClauses.js';
4
+
5
+ test('all is the only transaction clause', () => {
6
+ assert.deepEqual(Object.keys(segmentDefinitionClauses), ['all']);
7
+ });
8
+
9
+ test('amount bounds apply to the same transaction as the date window', () => {
10
+ const { text, eql } = all.optionsToEQL({ start: '2026-01-01', minAmount: 100, maxAmount: '500' });
11
+ assert.deepEqual(eql.conditions, [
12
+ "transaction.ts>='2026-01-01T00:00:00.000Z'",
13
+ 'transaction.amount>=100',
14
+ 'transaction.amount<=500'
15
+ ]);
16
+ assert.equal(text, 'Has any transactions with ts >= 2026-01-01T00:00:00.000Z with amount >= 100 with amount <= 500');
17
+ });
18
+
19
+ test('blank amounts are ignored and invalid amounts throw', () => {
20
+ assert.deepEqual(all.optionsToEQL({ minAmount: '', maxAmount: null }).eql.conditions, []);
21
+ assert.throws(() => all.optionsToEQL({ minAmount: 'abc' }), /Invalid minAmount: abc/);
22
+ assert.throws(() => all.optionsToEQL({ maxAmount: '1; drop table person' }), /Invalid maxAmount/);
23
+ });