@engine9/schemas 1.10.1 → 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.
- package/README.md +3 -3
- package/channels/email/README.md +4 -4
- package/channels/email/index.js +3 -3
- package/channels/email/{search.js → segmentDefinitionClauses.js} +6 -4
- package/channels/email/segments.js +7 -7
- package/event/README.md +2 -2
- package/event/index.js +3 -3
- package/event/{search.js → segmentDefinitionClauses.js} +2 -1
- package/package.json +2 -2
- package/person/index.js +6 -7
- package/person_address/index.js +4 -1
- package/person_address/segmentDefinitionClauses.js +98 -0
- package/person_address/segmentDefinitionClauses.test.js +46 -0
- package/person_email/README.md +3 -3
- package/person_email/index.js +3 -3
- package/person_email/{search.js → segmentDefinitionClauses.js} +2 -1
- package/person_email/segments.js +2 -2
- package/person_hash/README.md +1 -1
- package/person_hash/index.js +3 -3
- package/person_hash/{search.js → segmentDefinitionClauses.js} +2 -2
- package/person_name/README.md +3 -3
- package/person_name/index.js +3 -3
- package/person_name/{search.js → segmentDefinitionClauses.js} +2 -1
- package/person_phone/README.md +2 -2
- package/person_phone/index.js +3 -3
- package/person_phone/{search.js → segmentDefinitionClauses.js} +2 -1
- package/person_phone/segments.js +1 -1
- package/person_remote/index.js +10 -10
- package/plugin/schema.js +1 -1
- package/segment/index.js +3 -3
- package/segment/schema.js +3 -3
- package/segment/{search.js → segmentDefinitionClauses.js} +4 -3
- package/stacks/limited-pii/stack.json +1 -1
- package/stacks/stacks.test.js +5 -0
- package/stacks/standard/stack.json +1 -1
- package/timeline/index.js +6 -2
- package/timeline/segmentDefinitionClauses.js +56 -0
- package/timeline/segmentDefinitionClauses.test.js +37 -0
- package/transaction/core/README.md +4 -5
- package/transaction/core/index.js +3 -3
- package/transaction/core/{search.js → segmentDefinitionClauses.js} +36 -25
- package/transaction/core/segmentDefinitionClauses.test.js +23 -0
- package/transaction/core/segments.js +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ this code as-is.
|
|
|
7
7
|
|
|
8
8
|
## Documentation
|
|
9
9
|
|
|
10
|
-
Each schema plugin is documented in its own `README.md`. That file is the human-readable contract: what the schema plugin is for, the data model, inbound/outbound behavior,
|
|
10
|
+
Each schema plugin is documented in its own `README.md`. That file is the human-readable contract: what the schema plugin is for, the data model, inbound/outbound behavior, segment definition clauses, **predefined segments**, and UI. Keep implementation details in code comments; keep audience and membership rules in the README. Dashboards live on `@engine9/plugins/reports/<area>`, not in this package.
|
|
11
11
|
|
|
12
12
|
Schema plugins with predefined segments:
|
|
13
13
|
|
|
@@ -22,9 +22,9 @@ See [`skills/create-engine9-plugin/SKILL.md`](../skills/create-engine9-plugin/SK
|
|
|
22
22
|
|
|
23
23
|
- **Plugin uniqueness:** `SchemaWorker.install` reuses an existing `plugin` row when the path is unique: `metadata.unique` if set, otherwise `@engine9/schemas/*` (except `person_custom`, which is `unique: false`). Native plugins that set `unique: true` behave the same. Third-party plugins and `person_custom` may have more than one instance per path; pass `id` to update an existing non-unique row.
|
|
24
24
|
|
|
25
|
-
- **Public module surface:** Export only the standard building blocks documented in [`skills/create-engine9-plugin/SKILL.md`](../skills/create-engine9-plugin/SKILL.md): for example `metadata`, optional `schema`, `transforms`, `
|
|
25
|
+
- **Public module surface:** Export only the standard building blocks documented in [`skills/create-engine9-plugin/SKILL.md`](../skills/create-engine9-plugin/SKILL.md): for example `metadata`, optional `schema`, `transforms`, `segmentDefinitionClauses`, `segments`, `metrics`, and the default object that aggregates them. Do **not** export `reports` from schema plugins — dashboards belong on `@engine9/plugins/reports/<area>`. Do **not** export ad hoc hooks (such as custom install helpers or segment–plugin wiring) from schema plugins; the server is responsible for any special install-time behavior tied to a specific schema plugin path.
|
|
26
26
|
|
|
27
|
-
- **Segments, universes, and `plugin_id`:** Saved segments use `segment.plugin_id` = the schema plugin (or plugin) that **owns the definition**. A definition’s **`universe`** describes the span of source data searched across; input EQL entries select timeline stores, while person/table entries can restrict eligible people. Explicit `pluginId` values inside
|
|
27
|
+
- **Segments, universes, and `plugin_id`:** Saved segments use `segment.plugin_id` = the schema plugin (or plugin) that **owns the definition**. A definition’s **`universe`** describes the span of source data searched across; input EQL entries select timeline stores, while person/table entries can restrict eligible people. Explicit `pluginId` values inside a clause's options may narrow that span further, so a segment can evaluate data tied to other deployed plugins without changing its owning `plugin_id`. Export bundles use the same universe vocabulary for table and input-file artifacts.
|
|
28
28
|
|
|
29
29
|
## License
|
|
30
30
|
|
package/channels/email/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Email Channel Schema Plugin
|
|
2
2
|
|
|
3
|
-
The `@engine9/schemas/channels/email` plugin defines email-channel
|
|
3
|
+
The `@engine9/schemas/channels/email` plugin defines the email-channel segment definition clause and engagement segments. It does not create its own tables. Segment builds read `global_message`, `input`, and `timeline` rows produced by email message plugins.
|
|
4
4
|
|
|
5
5
|
It depends on `@engine9/schemas/person` because membership is always a set of people.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Segment definition clauses
|
|
8
8
|
|
|
9
9
|
`emailEngagement` finds people who opened or clicked email in a rolling window.
|
|
10
10
|
|
|
@@ -14,13 +14,13 @@ It depends on `@engine9/schemas/person` because membership is always a set of pe
|
|
|
14
14
|
| `timelineEntryType` | `EMAIL_OPEN` or `EMAIL_CLICK` |
|
|
15
15
|
| `pluginId` | Optional. When set, only that plugin's inputs are included. When empty, scope comes from the segment universe instead |
|
|
16
16
|
|
|
17
|
-
The
|
|
17
|
+
The clause looks at `timeline` rows whose `entry_type_id` matches the engagement type and whose `ts` is in the window. It always joins `timeline.input_id = input.id`, so only timeline entries for inputs present in the build are counted.
|
|
18
18
|
|
|
19
19
|
## Segments
|
|
20
20
|
|
|
21
21
|
Predefined audiences shipped with this schema plugin. On install, each key becomes a `segment` row whose definition path is `@engine9/schemas/channels/email:segments:<key>`.
|
|
22
22
|
|
|
23
|
-
Every segment here uses the same **universe** and the `emailEngagement`
|
|
23
|
+
Every segment here uses the same **universe** and the `emailEngagement` clause. The universe and the clause do different jobs, and both are required:
|
|
24
24
|
|
|
25
25
|
- **Universe** chooses *which email sends* can contribute entries: messages on the email channel published in the last **90 days** (`global_message.channel = 'email'` and `publish_date` within 90 days). Those message ids become the `input` rows for the build.
|
|
26
26
|
- **Search** chooses *which people* from those sends belong in the audience: a recent open or click on the timeline, inside the segment's rolling window.
|
package/channels/email/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
2
2
|
import segments, { personSegmentTableName, universeEmailPublished90d } from './segments.js';
|
|
3
3
|
|
|
4
4
|
const metadata = {
|
|
@@ -9,13 +9,13 @@ const metadata = {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export { metadata };
|
|
12
|
-
export {
|
|
12
|
+
export { segmentDefinitionClauses };
|
|
13
13
|
export { segments };
|
|
14
14
|
export { personSegmentTableName };
|
|
15
15
|
export { universeEmailPublished90d };
|
|
16
16
|
export default {
|
|
17
17
|
metadata,
|
|
18
|
-
|
|
18
|
+
segmentDefinitionClauses,
|
|
19
19
|
segments,
|
|
20
20
|
personSegmentTableName,
|
|
21
21
|
universeEmailPublished90d
|
|
@@ -3,7 +3,7 @@ import { TIMELINE_ENTRY_TYPES } from '@engine9/input-tools';
|
|
|
3
3
|
/**
|
|
4
4
|
* Build EQL for email engagement (opens / clicks) within a rolling window.
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
6
|
+
* The clause always joins `timeline` → `input` on `timeline.input_id = input.id`.
|
|
7
7
|
* During a segment build the DuckDB `input` table is populated exclusively with
|
|
8
8
|
* the input ids discovered by the segment's `universe` entry (e.g.
|
|
9
9
|
* `universeEmailPublished90d`), so the join scopes results to the universe even
|
|
@@ -60,8 +60,9 @@ export const emailEngagement = {
|
|
|
60
60
|
type: 'object',
|
|
61
61
|
properties: {
|
|
62
62
|
pluginId: {
|
|
63
|
-
title: 'Plugin
|
|
64
|
-
type: 'string'
|
|
63
|
+
title: 'Plugin',
|
|
64
|
+
type: 'string',
|
|
65
|
+
resolveOptionsUsing: 'plugin'
|
|
65
66
|
},
|
|
66
67
|
windowDays: {
|
|
67
68
|
title: 'Window (days)',
|
|
@@ -81,6 +82,7 @@ export const emailEngagement = {
|
|
|
81
82
|
}
|
|
82
83
|
};
|
|
83
84
|
|
|
85
|
+
export const segmentDefinitionClauses = { emailEngagement };
|
|
84
86
|
export default {
|
|
85
|
-
|
|
87
|
+
segmentDefinitionClauses
|
|
86
88
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
const EMAIL_ENGAGEMENT_CLAUSE_PATH = '@engine9/schemas/channels/email:segment_definition_clause:emailEngagement';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* EQL over the message table: rows published in the last 90 days on the email channel.
|
|
@@ -6,12 +6,12 @@ const SEGMENT_SEARCH_PATH = '@engine9/schemas/channels/email:search:emailEngagem
|
|
|
6
6
|
* scanned for timeline parquet.
|
|
7
7
|
*
|
|
8
8
|
* During a segment build the input ids returned here are copied into the DuckDB `input`
|
|
9
|
-
* table. The
|
|
9
|
+
* table. The clause EQL (see `emailEngagement` in `segmentDefinitionClauses.js`) always joins
|
|
10
10
|
* `timeline.input_id = input.id`, so only timeline rows whose input is in **this**
|
|
11
11
|
* universe are considered — even when `pluginId` is empty. This means:
|
|
12
12
|
*
|
|
13
13
|
* - The **universe** controls *which* message/input stores supply timeline data.
|
|
14
|
-
* - The **
|
|
14
|
+
* - The **clause** applies time-window and entry-type filters *within* that scope.
|
|
15
15
|
*
|
|
16
16
|
* Both layers are required for correct segment membership counts.
|
|
17
17
|
*/
|
|
@@ -35,7 +35,7 @@ export function personSegmentTableName(tablePrefix, segmentName) {
|
|
|
35
35
|
/**
|
|
36
36
|
* Build a segment definition for email engagement (opens or clicks).
|
|
37
37
|
*
|
|
38
|
-
* `pluginId` is left empty intentionally: the
|
|
38
|
+
* `pluginId` is left empty intentionally: the `emailEngagement` clause
|
|
39
39
|
* always joins `timeline` → `input`, and the segment `universe` determines
|
|
40
40
|
* which input rows are present in DuckDB. This scopes the time-window query
|
|
41
41
|
* to the correct set of message inputs without hard-coding a plugin id.
|
|
@@ -44,10 +44,10 @@ function emailEngagementSegment(name, windowDays, timelineEntryType) {
|
|
|
44
44
|
return {
|
|
45
45
|
name,
|
|
46
46
|
universe: [universeEmailPublished90d],
|
|
47
|
-
|
|
47
|
+
definition: {
|
|
48
48
|
and: [
|
|
49
49
|
{
|
|
50
|
-
path:
|
|
50
|
+
path: EMAIL_ENGAGEMENT_CLAUSE_PATH,
|
|
51
51
|
options: {
|
|
52
52
|
pluginId: '',
|
|
53
53
|
windowDays,
|
|
@@ -61,7 +61,7 @@ function emailEngagementSegment(name, windowDays, timelineEntryType) {
|
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Rolling-window engagement segments. `pluginId` is empty so scope follows the
|
|
64
|
-
* segment `universe` (the `input` table join in the
|
|
64
|
+
* segment `universe` (the `input` table join in the clause EQL restricts to
|
|
65
65
|
* universe-provided message input ids).
|
|
66
66
|
*/
|
|
67
67
|
export const email_openers_30d = emailEngagementSegment('30-day email openers', 30, 'EMAIL_OPEN');
|
package/event/README.md
CHANGED
|
@@ -56,11 +56,11 @@ None in this package. Native event / calendar plugins should upsert `event` and
|
|
|
56
56
|
|
|
57
57
|
None.
|
|
58
58
|
|
|
59
|
-
##
|
|
59
|
+
## Segment definition clauses
|
|
60
60
|
|
|
61
61
|
| Key | Path | Purpose |
|
|
62
62
|
| --- | --- | --- |
|
|
63
|
-
| `responses` | `@engine9/schemas/event:
|
|
63
|
+
| `responses` | `@engine9/schemas/event:segment_definition_clause:responses` | Filter people by `person_event.response` and optional `eventId`. |
|
|
64
64
|
|
|
65
65
|
## Segments
|
|
66
66
|
|
package/event/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
|
-
import
|
|
2
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
3
3
|
|
|
4
4
|
const metadata = {
|
|
5
5
|
name: '@engine9/schemas/event',
|
|
@@ -9,9 +9,9 @@ const metadata = {
|
|
|
9
9
|
schemas: ['schema.js']
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export { metadata, schema,
|
|
12
|
+
export { metadata, schema, segmentDefinitionClauses };
|
|
13
13
|
export default {
|
|
14
14
|
metadata,
|
|
15
15
|
schema,
|
|
16
|
-
|
|
16
|
+
segmentDefinitionClauses
|
|
17
17
|
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@engine9/schemas",
|
|
3
|
-
"version": "1.
|
|
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
|
@@ -21,11 +21,10 @@ const transforms = {
|
|
|
21
21
|
// Saved export definitions still call @engine9/schemas/person:transforms:appendName.
|
|
22
22
|
appendName
|
|
23
23
|
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
title: 'Primary Info',
|
|
24
|
+
const person = {
|
|
25
|
+
title: 'Ids',
|
|
27
26
|
form: {
|
|
28
|
-
title: '
|
|
27
|
+
title: 'Ids',
|
|
29
28
|
type: 'object',
|
|
30
29
|
properties: {
|
|
31
30
|
ids: {
|
|
@@ -71,8 +70,8 @@ export const search = {
|
|
|
71
70
|
}
|
|
72
71
|
};
|
|
73
72
|
}
|
|
74
|
-
}
|
|
75
|
-
};
|
|
73
|
+
};
|
|
74
|
+
export const segmentDefinitionClauses = { person };
|
|
76
75
|
export { metadata };
|
|
77
76
|
export { schema };
|
|
78
77
|
export { transforms };
|
|
@@ -82,5 +81,5 @@ export default {
|
|
|
82
81
|
schema,
|
|
83
82
|
transforms,
|
|
84
83
|
metrics,
|
|
85
|
-
|
|
84
|
+
segmentDefinitionClauses
|
|
86
85
|
};
|
package/person_address/index.js
CHANGED
|
@@ -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
|
+
});
|
package/person_email/README.md
CHANGED
|
@@ -51,9 +51,9 @@ The plugin provides outbound transforms for appending email data to batches:
|
|
|
51
51
|
- `appendEmail` looks up `person_email` rows by `person_id` and appends `email` to each batch row. It can be filtered by `subscriptionStatus`, and it preserves any existing `email` value already on the row.
|
|
52
52
|
- `appendEmailHash` appends `email_hash_v1` by hashing the row's trimmed, lowercased `email` when the hash is not already present.
|
|
53
53
|
|
|
54
|
-
##
|
|
54
|
+
## Segment definition clauses
|
|
55
55
|
|
|
56
|
-
The `emails`
|
|
56
|
+
The `emails` clause selects people who have an email address, optionally filtered by:
|
|
57
57
|
|
|
58
58
|
- `subscriptionStatus`, using the same values as `subscription_status`
|
|
59
59
|
- `emailMatch`, matched against the `email` column with `LIKE`
|
|
@@ -70,7 +70,7 @@ Predefined audiences shipped with this schema plugin. On install, each key becom
|
|
|
70
70
|
| **Definition path** | `@engine9/schemas/person_email:segments:subscribers` |
|
|
71
71
|
| **Who is included** | People with at least one email whose `subscription_status` is `Subscribed` |
|
|
72
72
|
| **Who is excluded** | People whose emails are only `Not Subscribed`, `Unsubscribed`, `Bouncing`, or `Spam` |
|
|
73
|
-
| **How it is built** | The `emails`
|
|
73
|
+
| **How it is built** | The `emails` clause with `subscriptionStatus: Subscribed` |
|
|
74
74
|
| **Universe** | None — membership is current `person_email` rows, not a time window or message input set |
|
|
75
75
|
|
|
76
76
|
A person with mixed statuses is included if any of their addresses is `Subscribed`.
|
package/person_email/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
2
|
import extractEmailHashes from './transforms/inbound/extract_identifiers.js';
|
|
3
3
|
import upsertPersonEmail from './transforms/inbound/upsert_tables.js';
|
|
4
|
-
import
|
|
4
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
5
5
|
import appendEmail from './transforms/outbound/appendEmail.js';
|
|
6
6
|
import appendEmailHash from './transforms/outbound/appendEmailHash.js';
|
|
7
7
|
import segments from './segments.js';
|
|
@@ -24,12 +24,12 @@ export const transforms = {
|
|
|
24
24
|
};
|
|
25
25
|
export { metadata };
|
|
26
26
|
export { schema };
|
|
27
|
-
export {
|
|
27
|
+
export { segmentDefinitionClauses };
|
|
28
28
|
export { segments };
|
|
29
29
|
export default {
|
|
30
30
|
metadata,
|
|
31
31
|
schema,
|
|
32
|
-
|
|
32
|
+
segmentDefinitionClauses,
|
|
33
33
|
segments,
|
|
34
34
|
transforms
|
|
35
35
|
};
|
package/person_email/segments.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export const subscribers = {
|
|
2
2
|
name: 'Email Subscribers',
|
|
3
|
-
|
|
3
|
+
definition: {
|
|
4
4
|
and: [
|
|
5
5
|
{
|
|
6
|
-
path: '@engine9/schemas/person_email:
|
|
6
|
+
path: '@engine9/schemas/person_email:segment_definition_clause:emails',
|
|
7
7
|
options: {
|
|
8
8
|
subscriptionStatus: 'Subscribed'
|
|
9
9
|
}
|
package/person_hash/README.md
CHANGED
|
@@ -65,6 +65,6 @@ The `upsertPersonHash` transform writes only hash columns to `person_hash_email`
|
|
|
65
65
|
|
|
66
66
|
`PersonWorker.search` `emails` / `phones` match `person_email` / `person_phone` when those tables exist, and also hash the query (or accept SHA-256 / MD5 hex) against `person_hash_email` / `person_hash_phone`. If the caller passed emails or phones and neither table exists, the clause matches nobody (`1=0`). Related summaries include `email_hashes` / `phone_hashes` when those tables are present.
|
|
67
67
|
|
|
68
|
-
The plugin
|
|
68
|
+
The plugin exports `segmentDefinitionClauses.emailHashes` and `segmentDefinitionClauses.phoneHashes` for segment definitions (`@engine9/schemas/person_hash:segment_definition_clause:emailHashes`).
|
|
69
69
|
|
|
70
70
|
Default warehouse export includes the hash tables (export skips them when they do not exist). When utilities/limited-pii `exclude_pii` is set and `tables` is not explicit, `person_name` / `person_email` / `person_phone` / `person_address` are omitted from that default list.
|
package/person_hash/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
|
-
import
|
|
2
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
3
3
|
import extractContactHashes from './transforms/inbound/extract_identifiers.js';
|
|
4
4
|
import upsertPersonHash from './transforms/inbound/upsert_tables.js';
|
|
5
5
|
import appendEmailHash from './transforms/outbound/appendEmailHash.js';
|
|
@@ -28,10 +28,10 @@ export const transforms = {
|
|
|
28
28
|
|
|
29
29
|
export { metadata };
|
|
30
30
|
export { schema };
|
|
31
|
-
export {
|
|
31
|
+
export { segmentDefinitionClauses };
|
|
32
32
|
export default {
|
|
33
33
|
metadata,
|
|
34
34
|
schema,
|
|
35
35
|
transforms,
|
|
36
|
-
|
|
36
|
+
segmentDefinitionClauses
|
|
37
37
|
};
|
package/person_name/README.md
CHANGED
|
@@ -54,9 +54,9 @@ Primary key: `person_id`.
|
|
|
54
54
|
|
|
55
55
|
`@engine9/schemas/person:transforms:appendName` is the same transform, kept so saved export definitions keep working.
|
|
56
56
|
|
|
57
|
-
##
|
|
57
|
+
## Segment definition clauses
|
|
58
58
|
|
|
59
|
-
`names` matches people by `given_name` and/or `family_name` with `LIKE`.
|
|
59
|
+
`names` matches people by `given_name` and/or `family_name` with `LIKE`. With neither, it selects everyone who has a name row.
|
|
60
60
|
|
|
61
61
|
## Upgrading accounts created before this plugin
|
|
62
62
|
|
|
@@ -66,4 +66,4 @@ Before this plugin, the names were `person.given_name` and `person.family_name`.
|
|
|
66
66
|
- `npx e9core upgrade --db <connection>` for a SQLite or MySQL database
|
|
67
67
|
- `npx e9core upgrade --url https://<site>` for a Cloudflare site (it posts to `/api/admin/upgrade` with `E9_ADMIN_API_KEY`)
|
|
68
68
|
|
|
69
|
-
The upgrade installs this plugin and copies every non-blank name into `person_name`. It then refreshes the `person` plugin's inbound snapshot
|
|
69
|
+
The upgrade installs this plugin and copies every non-blank name into `person_name`. It then refreshes the `person` plugin's inbound snapshot, so later loads write `person_name` only. The columns stay on `person` with the names as of the upgrade. Dropping them is a separate, opt-in step (`drop_name_columns`), for when nothing reads them. On a PII-free account the upgrade does not install this plugin, and the opt-in drop removes the columns only when they are empty, or when you also pass `discard_names`.
|
package/person_name/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
2
|
import upsertPersonName from './transforms/inbound/upsert_tables.js';
|
|
3
3
|
import appendName from './transforms/outbound/appendName.js';
|
|
4
|
-
import
|
|
4
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
5
5
|
const metadata = {
|
|
6
6
|
name: '@engine9/schemas/person_name',
|
|
7
7
|
unique: true,
|
|
@@ -19,10 +19,10 @@ export const transforms = {
|
|
|
19
19
|
};
|
|
20
20
|
export { metadata };
|
|
21
21
|
export { schema };
|
|
22
|
-
export {
|
|
22
|
+
export { segmentDefinitionClauses };
|
|
23
23
|
export default {
|
|
24
24
|
metadata,
|
|
25
25
|
schema,
|
|
26
|
-
|
|
26
|
+
segmentDefinitionClauses,
|
|
27
27
|
transforms
|
|
28
28
|
};
|
package/person_phone/README.md
CHANGED
|
@@ -49,9 +49,9 @@ The inbound `upsert` transform writes rows to `person_phone`.
|
|
|
49
49
|
|
|
50
50
|
`appendPhoneHash` hashes the row's normalized `phone` into `phone_hash_v1` when the hash is not already present.
|
|
51
51
|
|
|
52
|
-
##
|
|
52
|
+
## Segment definition clauses
|
|
53
53
|
|
|
54
|
-
The `phones`
|
|
54
|
+
The `phones` clause selects people who have a phone number, optionally filtered by:
|
|
55
55
|
|
|
56
56
|
- `smsStatus`, using the same values as `sms_status`
|
|
57
57
|
- `callStatus`, using the same values as `call_status`
|
package/person_phone/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
|
-
import
|
|
2
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
3
3
|
import segments from './segments.js';
|
|
4
4
|
import extractPhoneHashes from './transforms/inbound/extract_identifiers.js';
|
|
5
5
|
import upsertPersonPhone from './transforms/inbound/upsert_tables.js';
|
|
@@ -22,12 +22,12 @@ export const transforms = {
|
|
|
22
22
|
};
|
|
23
23
|
export { metadata };
|
|
24
24
|
export { schema };
|
|
25
|
-
export {
|
|
25
|
+
export { segmentDefinitionClauses };
|
|
26
26
|
export { segments };
|
|
27
27
|
export default {
|
|
28
28
|
metadata,
|
|
29
29
|
schema,
|
|
30
30
|
transforms,
|
|
31
|
-
|
|
31
|
+
segmentDefinitionClauses,
|
|
32
32
|
segments
|
|
33
33
|
};
|
package/person_phone/segments.js
CHANGED
package/person_remote/index.js
CHANGED
|
@@ -14,17 +14,17 @@ const metadata = {
|
|
|
14
14
|
upsert: ['upsertPersonRemote']
|
|
15
15
|
}
|
|
16
16
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
name: 'All remote people',
|
|
17
|
+
const all = {
|
|
18
|
+
title: 'Remote Ids',
|
|
19
|
+
name: 'All remote ids',
|
|
21
20
|
form: {
|
|
22
|
-
title: 'Remote
|
|
21
|
+
title: 'Remote Ids',
|
|
23
22
|
type: 'object',
|
|
24
23
|
properties: {
|
|
25
24
|
pluginId: {
|
|
26
|
-
title: 'Plugin
|
|
27
|
-
type: 'string'
|
|
25
|
+
title: 'Plugin',
|
|
26
|
+
type: 'string',
|
|
27
|
+
resolveOptionsUsing: 'plugin'
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
required: []
|
|
@@ -39,8 +39,8 @@ export const search = {
|
|
|
39
39
|
}
|
|
40
40
|
]
|
|
41
41
|
})
|
|
42
|
-
}
|
|
43
|
-
};
|
|
42
|
+
};
|
|
43
|
+
export const segmentDefinitionClauses = { all };
|
|
44
44
|
export const transforms = {
|
|
45
45
|
extractRemotePersonIds,
|
|
46
46
|
upsertPersonRemote,
|
|
@@ -53,6 +53,6 @@ export default {
|
|
|
53
53
|
metadata,
|
|
54
54
|
schema,
|
|
55
55
|
metrics,
|
|
56
|
-
|
|
56
|
+
segmentDefinitionClauses,
|
|
57
57
|
transforms
|
|
58
58
|
};
|
package/plugin/schema.js
CHANGED
package/segment/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
|
-
import
|
|
2
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
3
3
|
import upsert from './transforms/inbound/upsert.js';
|
|
4
4
|
const metadata = {
|
|
5
5
|
name: '@engine9/schemas/segment',
|
|
@@ -12,10 +12,10 @@ export const transforms = {
|
|
|
12
12
|
};
|
|
13
13
|
export { metadata };
|
|
14
14
|
export { schema };
|
|
15
|
-
export {
|
|
15
|
+
export { segmentDefinitionClauses };
|
|
16
16
|
export default {
|
|
17
17
|
metadata,
|
|
18
18
|
schema,
|
|
19
|
-
|
|
19
|
+
segmentDefinitionClauses,
|
|
20
20
|
transforms
|
|
21
21
|
};
|
package/segment/schema.js
CHANGED
|
@@ -23,15 +23,15 @@ export const tables = [
|
|
|
23
23
|
category: 'string', // A arbitrary type, user facing, not to be used for logic
|
|
24
24
|
definition_path: 'string', // a definition file path, will be used for configuring
|
|
25
25
|
stars: 'int', //number of stars, good for UI organizing
|
|
26
|
-
|
|
26
|
+
definition: 'json',
|
|
27
27
|
build_type: {
|
|
28
28
|
type: 'enum',
|
|
29
29
|
nullable: false,
|
|
30
30
|
default_value: 'list',
|
|
31
31
|
values: [
|
|
32
32
|
'list', //static list of people
|
|
33
|
-
'
|
|
34
|
-
'
|
|
33
|
+
'definition', // built by the segment definition
|
|
34
|
+
'scheduled_definition', // built on a schedule by the segment definition
|
|
35
35
|
'remote_count', // no person records, just counts, pulled from remote site
|
|
36
36
|
'remote', // pulled from remote plugin site
|
|
37
37
|
'manual' // created outside normal processing
|
|
@@ -6,8 +6,8 @@ export const segment = {
|
|
|
6
6
|
properties: {
|
|
7
7
|
segmentId: {
|
|
8
8
|
title: 'Segment',
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
type: 'string',
|
|
10
|
+
resolveOptionsUsing: 'segment'
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
required: []
|
|
@@ -59,6 +59,7 @@ export const segment = {
|
|
|
59
59
|
return { text, eql };
|
|
60
60
|
}
|
|
61
61
|
};
|
|
62
|
+
export const segmentDefinitionClauses = { segment };
|
|
62
63
|
export default {
|
|
63
|
-
|
|
64
|
+
segmentDefinitionClauses
|
|
64
65
|
};
|
|
@@ -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
|
],
|
package/stacks/stacks.test.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Transaction Core Schema Plugin
|
|
2
2
|
|
|
3
|
-
The `@engine9/schemas/transaction/core` plugin stores payment and gift transactions for people and provides the common transaction
|
|
3
|
+
The `@engine9/schemas/transaction/core` plugin stores payment and gift transactions for people and provides the common transaction segment definition clauses, metrics, and segment used by imports, reports, and exports.
|
|
4
4
|
|
|
5
5
|
It depends on `@engine9/schemas/person` because every transaction belongs to a person through `person_id`.
|
|
6
6
|
|
|
@@ -53,10 +53,9 @@ The inbound `upsert` transform writes rows to `transaction`.
|
|
|
53
53
|
|
|
54
54
|
`appendTransactionSummary` looks up a person's transactions and attaches summary fields such as count, revenue, first/last/smallest/largest gifts, and recurring totals.
|
|
55
55
|
|
|
56
|
-
##
|
|
56
|
+
## Segment definition clauses
|
|
57
57
|
|
|
58
|
-
- `all`
|
|
59
|
-
- `minimum` finds 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
|
|
|
@@ -74,7 +73,7 @@ Predefined audiences shipped with this schema plugin. On install, each key becom
|
|
|
74
73
|
| **Definition path** | `@engine9/schemas/transaction/core:segments:customers` |
|
|
75
74
|
| **Who is included** | People who have at least one row in `transaction` |
|
|
76
75
|
| **Who is excluded** | People with no transactions |
|
|
77
|
-
| **How it is built** |
|
|
76
|
+
| **How it is built** | The `all` clause, which selects `person_id` from `transaction` with no extra filters |
|
|
78
77
|
| **Universe** | None — any transaction from any plugin, date, or amount counts |
|
|
79
78
|
|
|
80
79
|
Refunds, recurring gifts, and $0 rows are not filtered out. If a person has ever appeared on a transaction row, they are a customer.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import schema from './schema.js';
|
|
2
|
-
import
|
|
2
|
+
import { segmentDefinitionClauses } from './segmentDefinitionClauses.js';
|
|
3
3
|
import metrics from './metrics.js';
|
|
4
4
|
import segments from './segments.js';
|
|
5
5
|
import upsert from './transforms/inbound/upsert_tables.js';
|
|
@@ -19,13 +19,13 @@ export const transforms = {
|
|
|
19
19
|
export { metadata };
|
|
20
20
|
export { schema };
|
|
21
21
|
export { metrics };
|
|
22
|
-
export {
|
|
22
|
+
export { segmentDefinitionClauses };
|
|
23
23
|
export { segments };
|
|
24
24
|
export default {
|
|
25
25
|
metadata,
|
|
26
26
|
schema,
|
|
27
27
|
metrics,
|
|
28
28
|
transforms,
|
|
29
|
-
|
|
29
|
+
segmentDefinitionClauses,
|
|
30
30
|
segments
|
|
31
31
|
};
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
};
|
|
95
|
+
export const segmentDefinitionClauses = { all };
|
|
84
96
|
export default {
|
|
85
|
-
|
|
86
|
-
minimum
|
|
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
|
+
});
|