@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.
Files changed (122) hide show
  1. package/.eslintignore +1 -0
  2. package/.prettierrc +7 -0
  3. package/AGENTS.md +10 -0
  4. package/LICENSE +21 -0
  5. package/README.md +33 -0
  6. package/channels/email/README.md +83 -0
  7. package/channels/email/index.js +22 -0
  8. package/channels/email/search.js +86 -0
  9. package/channels/email/segments.js +81 -0
  10. package/eslint.config.js +21 -0
  11. package/event/README.md +79 -0
  12. package/event/index.js +17 -0
  13. package/event/schema.js +111 -0
  14. package/event/search.js +51 -0
  15. package/message/index.js +8 -0
  16. package/message/schema.js +130 -0
  17. package/message/ui.console.json5 +233 -0
  18. package/package.json +26 -0
  19. package/person/index.js +100 -0
  20. package/person/metrics.js +17 -0
  21. package/person/schema.js +47 -0
  22. package/person/transforms/inbound/normalize_field_names.js +16 -0
  23. package/person/transforms/inbound/upsert_tables.js +21 -0
  24. package/person/transforms/outbound/appendName.js +32 -0
  25. package/person/transforms/simpleMap.js +34 -0
  26. package/person/ui.console.json5 +165 -0
  27. package/person/upsert.test.js +72 -0
  28. package/person_address/index.js +30 -0
  29. package/person_address/normalize.js +232 -0
  30. package/person_address/normalize.test.js +69 -0
  31. package/person_address/schema.js +49 -0
  32. package/person_address/transforms/inbound/normalize.js +23 -0
  33. package/person_address/transforms/inbound/upsert_tables.js +51 -0
  34. package/person_address/transforms/outbound/appendAddress.js +87 -0
  35. package/person_address/ui.console.json5 +31 -0
  36. package/person_custom/index.js +24 -0
  37. package/person_custom/transforms/appendRemoteId.js +31 -0
  38. package/person_custom/transforms/inbound/upsert_tables.js +31 -0
  39. package/person_email/README.md +82 -0
  40. package/person_email/index.js +35 -0
  41. package/person_email/schema.js +49 -0
  42. package/person_email/search.js +61 -0
  43. package/person_email/segments.js +16 -0
  44. package/person_email/transforms/inbound/extract_identifiers.js +33 -0
  45. package/person_email/transforms/inbound/upsert_tables.js +142 -0
  46. package/person_email/transforms/outbound/appendEmail.js +32 -0
  47. package/person_email/transforms/outbound/appendEmailHash.js +20 -0
  48. package/person_email/transforms/outbound/tables.js +14 -0
  49. package/person_email/ui.console.json5 +58 -0
  50. package/person_email/upsert.test.js +126 -0
  51. package/person_hash/README.md +70 -0
  52. package/person_hash/hash.js +103 -0
  53. package/person_hash/hash.test.js +165 -0
  54. package/person_hash/index.js +37 -0
  55. package/person_hash/schema.js +54 -0
  56. package/person_hash/search.js +86 -0
  57. package/person_hash/transforms/inbound/extract_identifiers.js +70 -0
  58. package/person_hash/transforms/inbound/upsert_tables.js +105 -0
  59. package/person_hash/transforms/outbound/appendEmailHash.js +42 -0
  60. package/person_hash/transforms/outbound/appendPhoneHash.js +42 -0
  61. package/person_hash/ui.console.json5 +80 -0
  62. package/person_phone/README.md +79 -0
  63. package/person_phone/index.js +33 -0
  64. package/person_phone/schema.js +58 -0
  65. package/person_phone/search.js +79 -0
  66. package/person_phone/segments.js +26 -0
  67. package/person_phone/transforms/inbound/extract_identifiers.js +59 -0
  68. package/person_phone/transforms/inbound/upsert_tables.js +119 -0
  69. package/person_phone/transforms/outbound/appendPhoneHash.js +37 -0
  70. package/person_phone/ui.console.json5 +31 -0
  71. package/person_phone/upsert.test.js +50 -0
  72. package/person_remote/index.js +58 -0
  73. package/person_remote/metrics.js +17 -0
  74. package/person_remote/schema.js +21 -0
  75. package/person_remote/transforms/inbound/extract_identifiers.js +34 -0
  76. package/person_remote/transforms/inbound/upsert_tables.js +92 -0
  77. package/person_remote/transforms/outbound/appendRemotePersonId.js +59 -0
  78. package/plugin/index.js +13 -0
  79. package/plugin/schema.js +70 -0
  80. package/plugin/settings.js +19 -0
  81. package/plugin/ui.console.json5 +79 -0
  82. package/report/index.js +7 -0
  83. package/report/ui.console.json5 +21 -0
  84. package/segment/index.js +21 -0
  85. package/segment/schema.js +81 -0
  86. package/segment/search.js +64 -0
  87. package/segment/transforms/inbound/upsert.js +44 -0
  88. package/segment/ui.console.json5 +142 -0
  89. package/segment_stats/index.js +16 -0
  90. package/segment_stats/schema.js +30 -0
  91. package/source_code/index.js +16 -0
  92. package/source_code/metrics.js +28 -0
  93. package/source_code/schema.js +29 -0
  94. package/source_code/ui.console.json5 +31 -0
  95. package/stacks/limited-pii/index.js +8 -0
  96. package/stacks/limited-pii/stack.json +22 -0
  97. package/stacks/stacks.test.js +27 -0
  98. package/stacks/standard/index.js +7 -0
  99. package/stacks/standard/stack.json +20 -0
  100. package/tag/index.js +13 -0
  101. package/tag/schema.js +23 -0
  102. package/task/index.js +13 -0
  103. package/task/schema.js +54 -0
  104. package/task/ui.console.json5 +60 -0
  105. package/timeline/index.js +18 -0
  106. package/timeline/schema.js +18 -0
  107. package/timeline/transforms/inbound/upsert_tables.js +22 -0
  108. package/timeline/ui.console.json5 +31 -0
  109. package/transaction/contact_details/index.js +11 -0
  110. package/transaction/contact_details/schema.js +22 -0
  111. package/transaction/core/README.md +79 -0
  112. package/transaction/core/attribute.test.js +231 -0
  113. package/transaction/core/index.js +31 -0
  114. package/transaction/core/metrics.js +20 -0
  115. package/transaction/core/schema.js +45 -0
  116. package/transaction/core/search.js +87 -0
  117. package/transaction/core/segments.js +14 -0
  118. package/transaction/core/transforms/appendTransactionSummary.js +101 -0
  119. package/transaction/core/transforms/attribute.js +327 -0
  120. package/transaction/core/transforms/inbound/upsert_tables.js +57 -0
  121. package/utilities/limited-pii/index.js +14 -0
  122. package/utilities/limited-pii/settings.js +12 -0
package/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ node_modules
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "tabWidth": 2,
5
+ "printWidth": 120,
6
+ "trailingComma": "none"
7
+ }
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
+ };
@@ -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
+ ];
@@ -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
+ };
@@ -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
+ };
@@ -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
+ };
@@ -0,0 +1,8 @@
1
+ const metadata = {
2
+ name: '@engine9/schemas/message',
3
+ schemas: ['schema.js']
4
+ };
5
+ export { metadata };
6
+ export default {
7
+ metadata
8
+ };