@engine9/schemas 1.9.0
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/.eslintignore +1 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +10 -0
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/channels/email/README.md +83 -0
- package/channels/email/index.js +22 -0
- package/channels/email/search.js +86 -0
- package/channels/email/segments.js +81 -0
- package/eslint.config.js +21 -0
- package/event/README.md +79 -0
- package/event/index.js +17 -0
- package/event/schema.js +111 -0
- package/event/search.js +51 -0
- package/message/index.js +8 -0
- package/message/schema.js +130 -0
- package/message/ui.console.json5 +233 -0
- package/package.json +26 -0
- package/person/index.js +100 -0
- package/person/metrics.js +17 -0
- package/person/schema.js +47 -0
- package/person/transforms/inbound/normalize_field_names.js +16 -0
- package/person/transforms/inbound/upsert_tables.js +21 -0
- package/person/transforms/outbound/appendName.js +32 -0
- package/person/transforms/simpleMap.js +34 -0
- package/person/ui.console.json5 +165 -0
- package/person/upsert.test.js +72 -0
- package/person_address/index.js +30 -0
- package/person_address/normalize.js +232 -0
- package/person_address/normalize.test.js +69 -0
- package/person_address/schema.js +49 -0
- package/person_address/transforms/inbound/normalize.js +23 -0
- package/person_address/transforms/inbound/upsert_tables.js +51 -0
- package/person_address/transforms/outbound/appendAddress.js +87 -0
- package/person_address/ui.console.json5 +31 -0
- package/person_custom/index.js +24 -0
- package/person_custom/transforms/appendRemoteId.js +31 -0
- package/person_custom/transforms/inbound/upsert_tables.js +31 -0
- package/person_email/README.md +82 -0
- package/person_email/index.js +35 -0
- package/person_email/schema.js +49 -0
- package/person_email/search.js +61 -0
- package/person_email/segments.js +16 -0
- package/person_email/transforms/inbound/extract_identifiers.js +33 -0
- package/person_email/transforms/inbound/upsert_tables.js +142 -0
- package/person_email/transforms/outbound/appendEmail.js +32 -0
- package/person_email/transforms/outbound/appendEmailHash.js +20 -0
- package/person_email/transforms/outbound/tables.js +14 -0
- package/person_email/ui.console.json5 +58 -0
- package/person_email/upsert.test.js +126 -0
- package/person_hash/README.md +70 -0
- package/person_hash/hash.js +103 -0
- package/person_hash/hash.test.js +165 -0
- package/person_hash/index.js +37 -0
- package/person_hash/schema.js +54 -0
- package/person_hash/search.js +86 -0
- package/person_hash/transforms/inbound/extract_identifiers.js +70 -0
- package/person_hash/transforms/inbound/upsert_tables.js +105 -0
- package/person_hash/transforms/outbound/appendEmailHash.js +42 -0
- package/person_hash/transforms/outbound/appendPhoneHash.js +42 -0
- package/person_hash/ui.console.json5 +80 -0
- package/person_phone/README.md +79 -0
- package/person_phone/index.js +33 -0
- package/person_phone/schema.js +58 -0
- package/person_phone/search.js +79 -0
- package/person_phone/segments.js +26 -0
- package/person_phone/transforms/inbound/extract_identifiers.js +59 -0
- package/person_phone/transforms/inbound/upsert_tables.js +119 -0
- package/person_phone/transforms/outbound/appendPhoneHash.js +37 -0
- package/person_phone/ui.console.json5 +31 -0
- package/person_phone/upsert.test.js +50 -0
- package/person_remote/index.js +58 -0
- package/person_remote/metrics.js +17 -0
- package/person_remote/schema.js +21 -0
- package/person_remote/transforms/inbound/extract_identifiers.js +34 -0
- package/person_remote/transforms/inbound/upsert_tables.js +92 -0
- package/person_remote/transforms/outbound/appendRemotePersonId.js +59 -0
- package/plugin/index.js +13 -0
- package/plugin/schema.js +70 -0
- package/plugin/settings.js +19 -0
- package/plugin/ui.console.json5 +79 -0
- package/report/index.js +7 -0
- package/report/ui.console.json5 +21 -0
- package/segment/index.js +21 -0
- package/segment/schema.js +81 -0
- package/segment/search.js +64 -0
- package/segment/transforms/inbound/upsert.js +44 -0
- package/segment/ui.console.json5 +142 -0
- package/segment_stats/index.js +16 -0
- package/segment_stats/schema.js +30 -0
- package/source_code/index.js +16 -0
- package/source_code/metrics.js +28 -0
- package/source_code/schema.js +29 -0
- package/source_code/ui.console.json5 +31 -0
- package/stacks/limited-pii/index.js +8 -0
- package/stacks/limited-pii/stack.json +22 -0
- package/stacks/stacks.test.js +27 -0
- package/stacks/standard/index.js +7 -0
- package/stacks/standard/stack.json +20 -0
- package/tag/index.js +13 -0
- package/tag/schema.js +23 -0
- package/task/index.js +13 -0
- package/task/schema.js +54 -0
- package/task/ui.console.json5 +60 -0
- package/timeline/index.js +18 -0
- package/timeline/schema.js +18 -0
- package/timeline/transforms/inbound/upsert_tables.js +22 -0
- package/timeline/ui.console.json5 +31 -0
- package/transaction/contact_details/index.js +11 -0
- package/transaction/contact_details/schema.js +22 -0
- package/transaction/core/README.md +79 -0
- package/transaction/core/attribute.test.js +231 -0
- package/transaction/core/index.js +31 -0
- package/transaction/core/metrics.js +20 -0
- package/transaction/core/schema.js +45 -0
- package/transaction/core/search.js +87 -0
- package/transaction/core/segments.js +14 -0
- package/transaction/core/transforms/appendTransactionSummary.js +101 -0
- package/transaction/core/transforms/attribute.js +327 -0
- package/transaction/core/transforms/inbound/upsert_tables.js +57 -0
- package/utilities/limited-pii/index.js +14 -0
- package/utilities/limited-pii/settings.js +12 -0
package/.eslintignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
node_modules
|
package/.prettierrc
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Agent guide — `@engine9/schemas`
|
|
2
|
+
|
|
3
|
+
## License
|
|
4
|
+
|
|
5
|
+
`@engine9/schemas` is MIT licensed. See [LICENSE](LICENSE). Use, copy,
|
|
6
|
+
modify, and distribute this code as-is. No further permission is required.
|
|
7
|
+
|
|
8
|
+
`@engine9/core`, `@engine9/id`, `demo-festival`, and `demo-id` are also MIT.
|
|
9
|
+
The private repositories `delegate` and `server` are not open source. Do not
|
|
10
|
+
copy code from those repositories under this license.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Engine9
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @engine9/schemas
|
|
2
|
+
|
|
3
|
+
The engine9 standard schemas: shared tables, inbound transforms, searches, and segments used by core and by plugins. Each directory with an `index.js` is a **schema plugin** (`@engine9/schemas/person_email`, `@engine9/schemas/transaction/core`, …). A schema plugin's table definitions are its `schema.js`. Building on these schema plugins lets other plugins share common tools and features while adding their own functionality.
|
|
4
|
+
|
|
5
|
+
This package was published as `@engine9/interfaces` through 1.8.1. `@engine9/core` still reads `@engine9/interfaces/...` paths stored before the rename; `PluginWorker.migratePackageRename` rewrites them.
|
|
6
|
+
|
|
7
|
+
This package is [MIT licensed](./LICENSE). Use, copy, modify, and distribute
|
|
8
|
+
this code as-is.
|
|
9
|
+
|
|
10
|
+
## Documentation
|
|
11
|
+
|
|
12
|
+
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, search, **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.
|
|
13
|
+
|
|
14
|
+
Schema plugins with predefined segments:
|
|
15
|
+
|
|
16
|
+
- [`person_email`](person_email/README.md) — Email Subscribers
|
|
17
|
+
- [`person_phone`](person_phone/README.md) — Textable People
|
|
18
|
+
- [`transaction/core`](transaction/core/README.md) — Customers
|
|
19
|
+
- [`channels/email`](channels/email/README.md) — rolling-window email openers and clickers
|
|
20
|
+
|
|
21
|
+
See [`skills/create-engine9-plugin/SKILL.md`](../skills/create-engine9-plugin/SKILL.md) for the README layout.
|
|
22
|
+
|
|
23
|
+
## Conventions
|
|
24
|
+
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
- **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`, `search`, `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.
|
|
28
|
+
|
|
29
|
+
- **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 `search` 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.
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
[MIT](./LICENSE). Use, copy, modify, and distribute this code as-is.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Email Channel Schema Plugin
|
|
2
|
+
|
|
3
|
+
The `@engine9/schemas/channels/email` plugin defines email-channel search 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
|
+
|
|
5
|
+
It depends on `@engine9/schemas/person` because membership is always a set of people.
|
|
6
|
+
|
|
7
|
+
## Search
|
|
8
|
+
|
|
9
|
+
`emailEngagement` finds people who opened or clicked email in a rolling window.
|
|
10
|
+
|
|
11
|
+
| Option | Meaning |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `windowDays` | Rolling window: `30`, `60`, or `90` days |
|
|
14
|
+
| `timelineEntryType` | `EMAIL_OPEN` or `EMAIL_CLICK` |
|
|
15
|
+
| `pluginId` | Optional. When set, only that plugin's inputs are included. When empty, scope comes from the segment universe instead |
|
|
16
|
+
|
|
17
|
+
The search 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
|
+
|
|
19
|
+
## Segments
|
|
20
|
+
|
|
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
|
+
|
|
23
|
+
Every segment here uses the same **universe** and the `emailEngagement` search. The universe and the search do different jobs, and both are required:
|
|
24
|
+
|
|
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
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
An open or click on a message published more than 90 days ago does not count, even if the entry itself is recent. `pluginId` is left empty so the universe, not a hardcoded plugin, decides which message stores are scanned.
|
|
29
|
+
|
|
30
|
+
### 30-day email openers
|
|
31
|
+
|
|
32
|
+
| | |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| **Key** | `email_openers_30d` |
|
|
35
|
+
| **Definition path** | `@engine9/schemas/channels/email:segments:email_openers_30d` |
|
|
36
|
+
| **Who is included** | People with an `EMAIL_OPEN` on a universe email in the last 30 days |
|
|
37
|
+
| **Who is excluded** | People with no open in that window, or whose only opens are on messages outside the 90-day published universe |
|
|
38
|
+
|
|
39
|
+
### 60-day email openers
|
|
40
|
+
|
|
41
|
+
| | |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| **Key** | `email_openers_60d` |
|
|
44
|
+
| **Definition path** | `@engine9/schemas/channels/email:segments:email_openers_60d` |
|
|
45
|
+
| **Who is included** | People with an `EMAIL_OPEN` on a universe email in the last 60 days |
|
|
46
|
+
|
|
47
|
+
### 90-day email openers
|
|
48
|
+
|
|
49
|
+
| | |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| **Key** | `email_openers_90d` |
|
|
52
|
+
| **Definition path** | `@engine9/schemas/channels/email:segments:email_openers_90d` |
|
|
53
|
+
| **Who is included** | People with an `EMAIL_OPEN` on a universe email in the last 90 days |
|
|
54
|
+
|
|
55
|
+
### 30-day email clickers
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| **Key** | `email_clickers_30d` |
|
|
60
|
+
| **Definition path** | `@engine9/schemas/channels/email:segments:email_clickers_30d` |
|
|
61
|
+
| **Who is included** | People with an `EMAIL_CLICK` on a universe email in the last 30 days |
|
|
62
|
+
| **Who is excluded** | People who only opened (and did not click), or whose only clicks are outside the window or universe |
|
|
63
|
+
|
|
64
|
+
### 60-day email clickers
|
|
65
|
+
|
|
66
|
+
| | |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| **Key** | `email_clickers_60d` |
|
|
69
|
+
| **Definition path** | `@engine9/schemas/channels/email:segments:email_clickers_60d` |
|
|
70
|
+
| **Who is included** | People with an `EMAIL_CLICK` on a universe email in the last 60 days |
|
|
71
|
+
|
|
72
|
+
### 90-day email clickers
|
|
73
|
+
|
|
74
|
+
| | |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| **Key** | `email_clickers_90d` |
|
|
77
|
+
| **Definition path** | `@engine9/schemas/channels/email:segments:email_clickers_90d` |
|
|
78
|
+
| **Who is included** | People with an `EMAIL_CLICK` on a universe email in the last 90 days |
|
|
79
|
+
|
|
80
|
+
## Reports and UI
|
|
81
|
+
|
|
82
|
+
This schema plugin does not ship reports. Email engagement and fundraising dashboards live on `@engine9/plugins/reports/messaging` (`email`, `email_transactions`). See [`plugins/reports/messaging/README.md`](../../../plugins/reports/messaging/README.md).
|
|
83
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import search from './search.js';
|
|
2
|
+
import segments, { personSegmentTableName, universeEmailPublished90d } from './segments.js';
|
|
3
|
+
|
|
4
|
+
const metadata = {
|
|
5
|
+
name: '@engine9/schemas/channels/email',
|
|
6
|
+
dependencies: {
|
|
7
|
+
'@engine9/schemas/person': '>=1.9.0'
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export { metadata };
|
|
12
|
+
export { search };
|
|
13
|
+
export { segments };
|
|
14
|
+
export { personSegmentTableName };
|
|
15
|
+
export { universeEmailPublished90d };
|
|
16
|
+
export default {
|
|
17
|
+
metadata,
|
|
18
|
+
search,
|
|
19
|
+
segments,
|
|
20
|
+
personSegmentTableName,
|
|
21
|
+
universeEmailPublished90d
|
|
22
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { TIMELINE_ENTRY_TYPES } from '@engine9/input-tools';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build EQL for email engagement (opens / clicks) within a rolling window.
|
|
5
|
+
*
|
|
6
|
+
* The search always joins `timeline` → `input` on `timeline.input_id = input.id`.
|
|
7
|
+
* During a segment build the DuckDB `input` table is populated exclusively with
|
|
8
|
+
* the input ids discovered by the segment's `universe` entry (e.g.
|
|
9
|
+
* `universeEmailPublished90d`), so the join scopes results to the universe even
|
|
10
|
+
* when no explicit `pluginId` is provided. When `pluginId` IS set, an additional
|
|
11
|
+
* `input.plugin_id` filter is applied.
|
|
12
|
+
*/
|
|
13
|
+
function buildEmailEngagementEql({ pluginId, windowDays, timelineEntryType }) {
|
|
14
|
+
const typeId = TIMELINE_ENTRY_TYPES[timelineEntryType];
|
|
15
|
+
if (typeof typeId !== 'number') {
|
|
16
|
+
throw new Error(`Invalid timelineEntryType for email engagement: ${timelineEntryType}`);
|
|
17
|
+
}
|
|
18
|
+
const days = parseInt(windowDays, 10);
|
|
19
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
20
|
+
throw new Error('windowDays must be a positive number');
|
|
21
|
+
}
|
|
22
|
+
const label =
|
|
23
|
+
timelineEntryType === 'EMAIL_OPEN'
|
|
24
|
+
? 'email open'
|
|
25
|
+
: timelineEntryType === 'EMAIL_CLICK'
|
|
26
|
+
? 'email click'
|
|
27
|
+
: timelineEntryType;
|
|
28
|
+
const hasPlugin = pluginId != null && String(pluginId).trim() !== '';
|
|
29
|
+
const conditions = [
|
|
30
|
+
{ eql: `timeline.entry_type_id=${typeId}` },
|
|
31
|
+
{ eql: `timeline.ts >= date_sub(now(), interval ${days} day)` }
|
|
32
|
+
];
|
|
33
|
+
if (hasPlugin) {
|
|
34
|
+
const safePluginId = String(pluginId).replace(/'/g, "''");
|
|
35
|
+
conditions.push({ eql: `input.plugin_id='${safePluginId}'` });
|
|
36
|
+
}
|
|
37
|
+
const text = hasPlugin
|
|
38
|
+
? `Has a ${label} from plugin ${String(pluginId)} in the last ${days} days`
|
|
39
|
+
: `Has a ${label} in the last ${days} days (scoped to universe inputs)`;
|
|
40
|
+
return {
|
|
41
|
+
text,
|
|
42
|
+
eql: {
|
|
43
|
+
table: 'timeline',
|
|
44
|
+
joins: [
|
|
45
|
+
{
|
|
46
|
+
table: 'input',
|
|
47
|
+
join_eql: 'timeline.input_id=input.id'
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
columns: ['person_id'],
|
|
51
|
+
conditions
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const emailEngagement = {
|
|
57
|
+
title: 'Email engagement (opens / clicks)',
|
|
58
|
+
form: {
|
|
59
|
+
title: 'Email engagement (opens / clicks)',
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
pluginId: {
|
|
63
|
+
title: 'Plugin ID',
|
|
64
|
+
type: 'string'
|
|
65
|
+
},
|
|
66
|
+
windowDays: {
|
|
67
|
+
title: 'Window (days)',
|
|
68
|
+
type: 'number',
|
|
69
|
+
enum: [30, 60, 90]
|
|
70
|
+
},
|
|
71
|
+
timelineEntryType: {
|
|
72
|
+
title: 'Engagement type',
|
|
73
|
+
type: 'string',
|
|
74
|
+
enum: ['EMAIL_OPEN', 'EMAIL_CLICK']
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
required: ['windowDays', 'timelineEntryType']
|
|
78
|
+
},
|
|
79
|
+
optionsToEQL(options) {
|
|
80
|
+
return buildEmailEngagementEql(options);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default {
|
|
85
|
+
emailEngagement
|
|
86
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const SEGMENT_SEARCH_PATH = '@engine9/schemas/channels/email:search:emailEngagement';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EQL over the message table: rows published in the last 90 days on the email channel.
|
|
5
|
+
* `message.id` is the input_id used when the message is loaded as an input; stores are
|
|
6
|
+
* scanned for timeline parquet.
|
|
7
|
+
*
|
|
8
|
+
* During a segment build the input ids returned here are copied into the DuckDB `input`
|
|
9
|
+
* table. The search EQL (see `emailEngagement` in `search.js`) always joins
|
|
10
|
+
* `timeline.input_id = input.id`, so only timeline rows whose input is in **this**
|
|
11
|
+
* universe are considered — even when `pluginId` is empty. This means:
|
|
12
|
+
*
|
|
13
|
+
* - The **universe** controls *which* message/input stores supply timeline data.
|
|
14
|
+
* - The **search** applies time-window and entry-type filters *within* that scope.
|
|
15
|
+
*
|
|
16
|
+
* Both layers are required for correct segment membership counts.
|
|
17
|
+
*/
|
|
18
|
+
export const universeEmailPublished90d = {
|
|
19
|
+
type: 'inputs',
|
|
20
|
+
eql: {
|
|
21
|
+
table: 'global_message',
|
|
22
|
+
columns: [{ column: 'id', name: 'input_id' }],
|
|
23
|
+
conditions: [{ eql: `channel='email'` }, { eql: `publish_date >= date_sub(now(), interval 90 day)` }]
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Materialized membership table: person_segment_<table_prefix>_<segment_name> (trailing _ on prefix trimmed to avoid __). */
|
|
28
|
+
export function personSegmentTableName(tablePrefix, segmentName) {
|
|
29
|
+
const pre = String(tablePrefix ?? '')
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/_+$/u, '');
|
|
32
|
+
return `person_segment_${pre}_${segmentName}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a segment definition for email engagement (opens or clicks).
|
|
37
|
+
*
|
|
38
|
+
* `pluginId` is left empty intentionally: the search handler in `search.js`
|
|
39
|
+
* always joins `timeline` → `input`, and the segment `universe` determines
|
|
40
|
+
* which input rows are present in DuckDB. This scopes the time-window query
|
|
41
|
+
* to the correct set of message inputs without hard-coding a plugin id.
|
|
42
|
+
*/
|
|
43
|
+
function emailEngagementSegment(name, windowDays, timelineEntryType) {
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
universe: [universeEmailPublished90d],
|
|
47
|
+
search: {
|
|
48
|
+
and: [
|
|
49
|
+
{
|
|
50
|
+
path: SEGMENT_SEARCH_PATH,
|
|
51
|
+
options: {
|
|
52
|
+
pluginId: '',
|
|
53
|
+
windowDays,
|
|
54
|
+
timelineEntryType
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Rolling-window engagement segments. `pluginId` is empty so scope follows the
|
|
64
|
+
* segment `universe` (the `input` table join in the search EQL restricts to
|
|
65
|
+
* universe-provided message input ids).
|
|
66
|
+
*/
|
|
67
|
+
export const email_openers_30d = emailEngagementSegment('30-day email openers', 30, 'EMAIL_OPEN');
|
|
68
|
+
export const email_openers_60d = emailEngagementSegment('60-day email openers', 60, 'EMAIL_OPEN');
|
|
69
|
+
export const email_openers_90d = emailEngagementSegment('90-day email openers', 90, 'EMAIL_OPEN');
|
|
70
|
+
export const email_clickers_30d = emailEngagementSegment('30-day email clickers', 30, 'EMAIL_CLICK');
|
|
71
|
+
export const email_clickers_60d = emailEngagementSegment('60-day email clickers', 60, 'EMAIL_CLICK');
|
|
72
|
+
export const email_clickers_90d = emailEngagementSegment('90-day email clickers', 90, 'EMAIL_CLICK');
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
email_openers_30d,
|
|
76
|
+
email_openers_60d,
|
|
77
|
+
email_openers_90d,
|
|
78
|
+
email_clickers_30d,
|
|
79
|
+
email_clickers_60d,
|
|
80
|
+
email_clickers_90d
|
|
81
|
+
};
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import nodePlugin from 'eslint-plugin-n';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
nodePlugin.configs['flat/recommended-module'],
|
|
6
|
+
js.configs.recommended,
|
|
7
|
+
{
|
|
8
|
+
rules: {
|
|
9
|
+
'n/exports-style': ['error', 'module.exports'],
|
|
10
|
+
'n/no-unsupported-features/node-builtins': 'off', // fetch and a lot of good things are here
|
|
11
|
+
'n/no-extraneous-require': 'off', // a few libraries use this
|
|
12
|
+
'n/no-extraneous-import': 'off', // a few libraries use this
|
|
13
|
+
'n/no-unpublished-require': 'off', // a few local calls use this
|
|
14
|
+
'n/no-unpublished-import': 'off' // devDependencies and local imports
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
files: ['**/*.js'],
|
|
19
|
+
languageOptions: { sourceType: 'module' }
|
|
20
|
+
}
|
|
21
|
+
];
|
package/event/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Event Schema Plugin
|
|
2
|
+
|
|
3
|
+
`@engine9/schemas/event` is the shared calendar-style event contract: an `event` row plus `person_event` RSVP / attendance links. Install on accounts that need events; it is not part of the standard stack.
|
|
4
|
+
|
|
5
|
+
Depends on `@engine9/schemas/person` because every `person_event` row belongs to a person through `person_id`.
|
|
6
|
+
|
|
7
|
+
## Data Model
|
|
8
|
+
|
|
9
|
+
### `event`
|
|
10
|
+
|
|
11
|
+
Minimum useful calendar event. Names track common iCal `VEVENT` properties (RFC 5545) without recurrence or structured geo.
|
|
12
|
+
|
|
13
|
+
| Field | Type | iCal | Description |
|
|
14
|
+
| --- | --- | --- | --- |
|
|
15
|
+
| `id` | `id_uuid` | `UID` | Event id. |
|
|
16
|
+
| `name` | `string` | `SUMMARY` | Short title (required). |
|
|
17
|
+
| `description` | `string` | `DESCRIPTION` | Longer body / notes. |
|
|
18
|
+
| `start` | `datetime` | `DTSTART` | Start instant (store UTC; display with `timezone`). |
|
|
19
|
+
| `end` | `datetime` | `DTEND` | End instant; null when open-ended or unknown. |
|
|
20
|
+
| `timezone` | `string` | `TZID` | IANA zone for display, e.g. `America/New_York`. |
|
|
21
|
+
| `all_day` | `boolean` | date vs date-time | True when the event is a calendar day without a clock time. Defaults to `false`. |
|
|
22
|
+
| `location` | `string` | `LOCATION` | Venue or place text. |
|
|
23
|
+
| `url` | `string` | `URL` | Canonical or ticket link. |
|
|
24
|
+
| `status` | `string` | `STATUS` | `Tentative`, `Confirmed` (default), or `Cancelled`. |
|
|
25
|
+
| `source_input_id` | `foreign_uuid` | — | Input that created or owns the row. |
|
|
26
|
+
| `created_at` / `modified_at` | timestamps | `CREATED` / `LAST-MODIFIED` | Row lifecycle. |
|
|
27
|
+
|
|
28
|
+
Indexes: primary `id`; `start`, `end`, `status`, `source_input_id`.
|
|
29
|
+
|
|
30
|
+
**Intentionally omitted (v1):** `RRULE` / recurrence, `GEO`, `ORGANIZER`, remote event ids, `CATEGORIES`, `CLASS`, `TRANSP`, `SEQUENCE`. Plugins can keep those in their own tables or remote payloads.
|
|
31
|
+
|
|
32
|
+
### `person_event`
|
|
33
|
+
|
|
34
|
+
One person linked to one event for invite / RSVP / attendance.
|
|
35
|
+
|
|
36
|
+
| Field | Type | iCal | Description |
|
|
37
|
+
| --- | --- | --- | --- |
|
|
38
|
+
| `id` | `id` | — | Row id. |
|
|
39
|
+
| `event_id` | `foreign_uuid` | — | Parent event. |
|
|
40
|
+
| `person_id` | `person_id` | — | Linked person. |
|
|
41
|
+
| `response` | `string` | `ATTENDEE` `PARTSTAT` | `Invited` (default, ≈ `NEEDS-ACTION`), `Accepted`, `Declined`, `Tentative`, or `Attended` (post-event check-in beyond iCal). |
|
|
42
|
+
| `role` | `string` | `ATTENDEE` `ROLE` | `Required` (default), `Optional`, `Chair`, or `Non-Participant`. |
|
|
43
|
+
| `responded_at` | `datetime` | — | When the person last set `response`. |
|
|
44
|
+
| `source_input_id` | `foreign_uuid` | — | Provenance input when imported. |
|
|
45
|
+
| `created_at` / `modified_at` | timestamps | — | Row lifecycle. |
|
|
46
|
+
|
|
47
|
+
Indexes: primary `id`; `person_id`, `event_id`, `response`; unique `(event_id, person_id)`.
|
|
48
|
+
|
|
49
|
+
**Attend / Decline:** upsert `person_event` with `response` `Accepted` or `Declined` (and set `responded_at`). Use `Tentative` for maybe, `Attended` after check-in, `Invited` until they answer.
|
|
50
|
+
|
|
51
|
+
## Inbound Behavior
|
|
52
|
+
|
|
53
|
+
None in this package. Native event / calendar plugins should upsert `event` and `person_event` (and may later contribute inbound transforms).
|
|
54
|
+
|
|
55
|
+
## Outbound Behavior
|
|
56
|
+
|
|
57
|
+
None.
|
|
58
|
+
|
|
59
|
+
## Search
|
|
60
|
+
|
|
61
|
+
| Key | Path | Purpose |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `responses` | `@engine9/schemas/event:search:responses` | Filter people by `person_event.response` and optional `eventId`. |
|
|
64
|
+
|
|
65
|
+
## Segments
|
|
66
|
+
|
|
67
|
+
None.
|
|
68
|
+
|
|
69
|
+
## Metrics
|
|
70
|
+
|
|
71
|
+
None.
|
|
72
|
+
|
|
73
|
+
## Reports and UI
|
|
74
|
+
|
|
75
|
+
None on this schema plugin. Dashboards belong on `@engine9/plugins/reports/<area>` if needed later.
|
|
76
|
+
|
|
77
|
+
## Settings
|
|
78
|
+
|
|
79
|
+
None.
|
package/event/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import schema from './schema.js';
|
|
2
|
+
import search from './search.js';
|
|
3
|
+
|
|
4
|
+
const metadata = {
|
|
5
|
+
name: '@engine9/schemas/event',
|
|
6
|
+
dependencies: {
|
|
7
|
+
'@engine9/schemas/person': '>=1.9.0'
|
|
8
|
+
},
|
|
9
|
+
schemas: ['schema.js']
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export { metadata, schema, search };
|
|
13
|
+
export default {
|
|
14
|
+
metadata,
|
|
15
|
+
schema,
|
|
16
|
+
search
|
|
17
|
+
};
|
package/event/schema.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broad calendar-style event + person RSVP.
|
|
3
|
+
* Field names align with common iCal VEVENT / ATTENDEE concepts (RFC 5545)
|
|
4
|
+
* without requiring recurrence (RRULE), GEO, or full ATTENDEE parameter sets.
|
|
5
|
+
*/
|
|
6
|
+
export const tables = [
|
|
7
|
+
{
|
|
8
|
+
name: 'event',
|
|
9
|
+
columns: {
|
|
10
|
+
id: 'id_uuid',
|
|
11
|
+
name: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
nullable: false,
|
|
14
|
+
description: 'Short title (iCal SUMMARY)'
|
|
15
|
+
},
|
|
16
|
+
description: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Longer body / notes (iCal DESCRIPTION)'
|
|
19
|
+
},
|
|
20
|
+
start: {
|
|
21
|
+
type: 'datetime',
|
|
22
|
+
nullable: false,
|
|
23
|
+
description: 'Event start (iCal DTSTART); store UTC, interpret with timezone'
|
|
24
|
+
},
|
|
25
|
+
end: {
|
|
26
|
+
type: 'datetime',
|
|
27
|
+
description: 'Event end (iCal DTEND); null when open-ended or duration unknown'
|
|
28
|
+
},
|
|
29
|
+
timezone: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'IANA timezone for start/end display (iCal TZID), e.g. America/New_York'
|
|
32
|
+
},
|
|
33
|
+
all_day: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
nullable: false,
|
|
36
|
+
default_value: false,
|
|
37
|
+
description: 'True when start/end are calendar dates without a clock time'
|
|
38
|
+
},
|
|
39
|
+
location: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'Human-readable place or venue (iCal LOCATION)'
|
|
42
|
+
},
|
|
43
|
+
url: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
description: 'Canonical or ticket URL (iCal URL)'
|
|
46
|
+
},
|
|
47
|
+
status: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
nullable: false,
|
|
50
|
+
default_value: 'Confirmed',
|
|
51
|
+
values: ['Tentative', 'Confirmed', 'Cancelled'],
|
|
52
|
+
description: 'Lifecycle of the event itself (iCal STATUS)'
|
|
53
|
+
},
|
|
54
|
+
source_input_id: 'foreign_uuid',
|
|
55
|
+
created_at: 'created_at',
|
|
56
|
+
modified_at: 'modified_at'
|
|
57
|
+
},
|
|
58
|
+
indexes: [
|
|
59
|
+
{ columns: 'id', primary: true },
|
|
60
|
+
{ columns: ['start'] },
|
|
61
|
+
{ columns: ['end'] },
|
|
62
|
+
{ columns: ['status'] },
|
|
63
|
+
{ columns: ['source_input_id'] }
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'person_event',
|
|
68
|
+
columns: {
|
|
69
|
+
id: 'id',
|
|
70
|
+
event_id: {
|
|
71
|
+
type: 'foreign_uuid',
|
|
72
|
+
nullable: false,
|
|
73
|
+
description: 'Event this person is linked to'
|
|
74
|
+
},
|
|
75
|
+
person_id: 'person_id',
|
|
76
|
+
response: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
nullable: false,
|
|
79
|
+
default_value: 'Invited',
|
|
80
|
+
values: ['Invited', 'Accepted', 'Declined', 'Tentative', 'Attended'],
|
|
81
|
+
description:
|
|
82
|
+
'RSVP / attendance (iCal ATTENDEE PARTSTAT). Invited≈NEEDS-ACTION; Accepted/Declined/Tentative match PARTSTAT; Attended is post-event check-in beyond iCal'
|
|
83
|
+
},
|
|
84
|
+
role: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
nullable: false,
|
|
87
|
+
default_value: 'Required',
|
|
88
|
+
values: ['Required', 'Optional', 'Chair', 'Non-Participant'],
|
|
89
|
+
description: 'Participation role (iCal ATTENDEE ROLE, simplified)'
|
|
90
|
+
},
|
|
91
|
+
responded_at: {
|
|
92
|
+
type: 'datetime',
|
|
93
|
+
description: 'When the person last set response (Attend / Decline / Tentative)'
|
|
94
|
+
},
|
|
95
|
+
source_input_id: 'foreign_uuid',
|
|
96
|
+
created_at: 'created_at',
|
|
97
|
+
modified_at: 'modified_at'
|
|
98
|
+
},
|
|
99
|
+
indexes: [
|
|
100
|
+
{ columns: 'id', primary: true },
|
|
101
|
+
{ columns: ['person_id'] },
|
|
102
|
+
{ columns: ['event_id'] },
|
|
103
|
+
{ columns: ['response'] },
|
|
104
|
+
{ columns: ['event_id', 'person_id'], unique: true }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
export default {
|
|
110
|
+
tables
|
|
111
|
+
};
|
package/event/search.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const RESPONSE_VALUES = ['Invited', 'Accepted', 'Declined', 'Tentative', 'Attended'];
|
|
2
|
+
|
|
3
|
+
export const responses = {
|
|
4
|
+
title: 'Event responses',
|
|
5
|
+
description: 'People linked to events by RSVP / attendance response',
|
|
6
|
+
form: {
|
|
7
|
+
title: 'Event responses',
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
response: {
|
|
11
|
+
type: 'string',
|
|
12
|
+
enum: RESPONSE_VALUES
|
|
13
|
+
},
|
|
14
|
+
eventId: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'Limit to one event UUID'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
required: []
|
|
20
|
+
},
|
|
21
|
+
optionsToEQL(options) {
|
|
22
|
+
const { response, eventId } = options;
|
|
23
|
+
let text = 'Has an event response';
|
|
24
|
+
const conditions = [];
|
|
25
|
+
if (response) {
|
|
26
|
+
text = `Event response is ${response}`;
|
|
27
|
+
conditions.push({
|
|
28
|
+
type: 'EQUALS',
|
|
29
|
+
values: [{ ref: { column: 'response' } }, { value: { value: response } }]
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
if (eventId) {
|
|
33
|
+
text += ` for event ${eventId}`;
|
|
34
|
+
conditions.push({
|
|
35
|
+
type: 'EQUALS',
|
|
36
|
+
values: [{ ref: { column: 'event_id' } }, { value: { value: eventId } }]
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
text,
|
|
41
|
+
eql: {
|
|
42
|
+
table: 'person_event',
|
|
43
|
+
conditions
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
responses
|
|
51
|
+
};
|