@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
@@ -0,0 +1,87 @@
1
+ export const description = 'Append the best postal address (all person_address columns)';
2
+
3
+ export const bindings = {
4
+ addresses: {
5
+ path: 'sql.query',
6
+ options: {
7
+ table: 'person_address',
8
+ columns: [
9
+ 'id',
10
+ 'person_id',
11
+ 'name',
12
+ 'type',
13
+ 'status',
14
+ 'street_1',
15
+ 'street_2',
16
+ 'street_3',
17
+ 'city',
18
+ 'region',
19
+ 'postal_code',
20
+ 'country',
21
+ 'subscription_status',
22
+ 'deliverability_score',
23
+ 'preference_order',
24
+ 'is_best_address',
25
+ 'created_at',
26
+ 'modified_at',
27
+ 'source_input_id'
28
+ ],
29
+ lookup: ['person_id'],
30
+ conditions: []
31
+ }
32
+ }
33
+ };
34
+
35
+ function truthyBest(v) {
36
+ return v === true || v === 1 || v === '1' || v === 't';
37
+ }
38
+
39
+ function pickAddressRow(rows) {
40
+ if (!rows?.length) return null;
41
+ const best = rows.find((r) => truthyBest(r.is_best_address));
42
+ if (best) return best;
43
+ return rows.slice().sort((a, b) => (Number(a.preference_order) || 0) - (Number(b.preference_order) || 0))[0];
44
+ }
45
+
46
+ export const transform = ({ batch, addresses, options = {} }) => {
47
+ const { subscriptionStatus } = options;
48
+ let filter = () => true;
49
+ if (subscriptionStatus) filter = (d) => d.subscription_status === subscriptionStatus;
50
+
51
+ const filtered = addresses.filter(filter);
52
+ const byPerson = {};
53
+ for (const a of filtered) {
54
+ if (!byPerson[a.person_id]) byPerson[a.person_id] = [];
55
+ byPerson[a.person_id].push(a);
56
+ }
57
+
58
+ batch.forEach((data) => {
59
+ const row = pickAddressRow(byPerson[data.person_id]);
60
+ if (!row) return;
61
+
62
+ data.person_address_id = data.person_address_id ?? row.id ?? null;
63
+ data.name = data.name || row.name || null;
64
+ data.type = data.type || row.type || null;
65
+ data.status = data.status || row.status || null;
66
+ data.street_1 = data.street_1 || row.street_1 || null;
67
+ data.street_2 = data.street_2 || row.street_2 || null;
68
+ data.street_3 = data.street_3 || row.street_3 || null;
69
+ data.city = data.city || row.city || null;
70
+ data.region = data.region || row.region || null;
71
+ data.postal_code = data.postal_code || row.postal_code || null;
72
+ data.country = data.country || row.country || null;
73
+ data.subscription_status = data.subscription_status || row.subscription_status || null;
74
+ data.deliverability_score = data.deliverability_score ?? row.deliverability_score ?? null;
75
+ data.preference_order = data.preference_order ?? row.preference_order ?? null;
76
+ data.is_best_address = data.is_best_address ?? row.is_best_address ?? null;
77
+ data.created_at = data.created_at ?? row.created_at ?? null;
78
+ data.modified_at = data.modified_at ?? row.modified_at ?? null;
79
+ data.source_input_id = data.source_input_id ?? row.source_input_id ?? null;
80
+ });
81
+ };
82
+
83
+ export default {
84
+ description,
85
+ bindings,
86
+ transform
87
+ };
@@ -0,0 +1,31 @@
1
+ {
2
+ routes: {
3
+ 'person/:id/*': {
4
+ components: {
5
+ sidebar: {
6
+ items: [
7
+ {
8
+ component: 'Link',
9
+ properties: {
10
+ label: 'Addresses',
11
+ to: 'address'
12
+ }
13
+ }
14
+ ]
15
+ },
16
+ main: [
17
+ {
18
+ path: 'address',
19
+ label: 'Addresses',
20
+ component: 'RecordDisplay',
21
+ properties: {
22
+ table: 'person_address',
23
+ conditions: [{ eql: 'person_id={{person_id}}' }],
24
+ rows: ['{{street}}']
25
+ }
26
+ }
27
+ ]
28
+ }
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ import upsertPersonCustom from './transforms/inbound/upsert_tables.js';
2
+ import appendRemoteId from './transforms/appendRemoteId.js';
3
+ const metadata = {
4
+ name: 'Custom Fields',
5
+ prefix: 'person_custom',
6
+ unique: false,
7
+ dependencies: {
8
+ '@engine9/schemas/person': '>=1.9.0'
9
+ },
10
+ // Inbound people pipeline slots -> transform export keys. Core expands the
11
+ // upsert once per installed row (one custom field table per table_prefix).
12
+ inbound: {
13
+ upsert: ['upsertPersonCustom']
14
+ }
15
+ };
16
+ export const transforms = {
17
+ upsertPersonCustom,
18
+ appendRemoteId
19
+ };
20
+ export { metadata };
21
+ export default {
22
+ metadata,
23
+ transforms
24
+ };
@@ -0,0 +1,31 @@
1
+ export const bindings = {
2
+ tablesToUpsert: { path: 'sql.tables.upsert' }
3
+ };
4
+ export const type = 'upsert';
5
+ export async function transform({ batch, options = {}, tablesToUpsert }) {
6
+ const {
7
+ table,
8
+ // schema,
9
+ columns
10
+ } = options;
11
+ batch.forEach((o) => {
12
+ const vals = {};
13
+ let hasData = false;
14
+ columns.forEach((f) => {
15
+ const v = o[`${table}.${f.name}`];
16
+ if (v !== undefined) {
17
+ hasData = true;
18
+ vals[f.name] = v;
19
+ }
20
+ });
21
+ if (hasData) {
22
+ tablesToUpsert[table] = (tablesToUpsert[table] || []).concat(vals);
23
+ }
24
+ });
25
+ return batch;
26
+ }
27
+ export default {
28
+ bindings,
29
+ type,
30
+ transform
31
+ };
@@ -0,0 +1,31 @@
1
+ export const bindings = {
2
+ tablesToUpsert: { path: 'sql.tables.upsert' }
3
+ };
4
+ export const type = 'upsert';
5
+ export async function transform({ batch, options = {}, tablesToUpsert }) {
6
+ const {
7
+ table,
8
+ // schema,
9
+ columns
10
+ } = options;
11
+ batch.forEach((o) => {
12
+ const vals = {};
13
+ let hasData = false;
14
+ columns.forEach((f) => {
15
+ const v = o[`${table}.${f.name}`];
16
+ if (v !== undefined) {
17
+ hasData = true;
18
+ vals[f.name] = v;
19
+ }
20
+ });
21
+ if (hasData) {
22
+ tablesToUpsert[table] = (tablesToUpsert[table] || []).concat(vals);
23
+ }
24
+ });
25
+ return batch;
26
+ }
27
+ export default {
28
+ bindings,
29
+ type,
30
+ transform
31
+ };
@@ -0,0 +1,82 @@
1
+ # Person Email Schema Plugin
2
+
3
+ The `@engine9/schemas/person_email` plugin stores email addresses for people and provides the common email-related behavior used by imports, matching, searches, segments, and exports.
4
+
5
+ It depends on `@engine9/schemas/person` because every email row belongs to a person through `person_id`.
6
+
7
+ Browser / public forms should use the column name **`email_type`** (never `type`).
8
+ See [`@engine9/id` forms docs](../../id/docs/forms.md).
9
+
10
+ ## Data Model
11
+
12
+ The plugin creates the `person_email` table. A person can have multiple email rows, but each `(email, person_id)` pair is unique.
13
+
14
+ | Field | Type | Description |
15
+ | --- | --- | --- |
16
+ | `id` | `id` | Primary row identifier. |
17
+ | `person_id` | `person_id` | Person that owns the email address. |
18
+ | `email_type` | `string` | Email category. Allowed values are `Personal`, `Work`, and `Other`. Defaults to `Personal`. |
19
+ | `email` | `string` | Email address. Inbound transforms trim whitespace but otherwise preserve the provided casing. Matching and hashing compare the lowercased value. |
20
+ | `subscription_status` | `string` | Email subscription state. Allowed values are `Not Subscribed`, `Subscribed`, `Unsubscribed`, `Bouncing`, and `Spam`. The schema default is `Not Subscribed`; the inbound upsert transform defaults newly imported emails to `Subscribed` when no status is provided. |
21
+ | `confirmation_status` | `string` | Opt-in confirmation state. Allowed values are `Not Confirmed`, `Confirmation Sent`, and `Confirmed`. Defaults to `Not Confirmed`. |
22
+ | `deliverability_score` | `int` | Deliverability score for the address. `0` represents undeliverable, `1` represents deliverable, and higher values can be used by callers for additional states. Defaults to `1`. |
23
+ | `preference_order` | `int` | Sort order for preferred emails, where `0` is first. Defaults to `0`. |
24
+ | `email_hash_v1` | `hash` | SHA-256 hash of the trimmed, lowercased email address. Used as an identifier and for privacy-preserving exports. |
25
+ | `source_input_id` | `foreign_uuid` | Input that originally created the email row. Existing rows keep their original source when updated. |
26
+ | `created_at` | `created_at` | Timestamp when the row was created. |
27
+ | `modified_at` | `modified_at` | Timestamp when the row was last modified. |
28
+
29
+ Indexes:
30
+
31
+ - `person_id`
32
+ - unique `(email, person_id)`
33
+
34
+ ## Inbound Behavior
35
+
36
+ The inbound `id` transform extracts an `email_hash_v1` identifier from either `email` or an existing `email_hash_v1` field. When an `email` is present and at least five characters after trimming, it is trimmed, lowercased for hashing, and hashed with SHA-256.
37
+
38
+ The inbound `upsert` transform writes rows to `person_email`.
39
+
40
+ - Emails are matched case-insensitively after trimming.
41
+ - Existing rows for the same `person_id` and email are updated instead of duplicated.
42
+ - The transform accepts either `email_subscription_status` or `subscription_status`.
43
+ - Newly inserted emails default to `Subscribed` if no status is provided.
44
+ - Unsubscribe-like entries set `subscription_status` to `Unsubscribed` when no status was provided. This includes `entry_type` values `EMAIL_UNSUBSCRIBE` and `EMAIL_SPAM`, plus `entry_type_id` values `44` and `48`.
45
+ - When an email is unsubscribed, all matching email rows for that address are also updated to `Unsubscribed`.
46
+
47
+ ## Outbound Behavior
48
+
49
+ The plugin provides outbound transforms for appending email data to batches:
50
+
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
+ - `appendEmailHash` appends `email_hash_v1` by hashing the row's trimmed, lowercased `email` when the hash is not already present.
53
+
54
+ ## Search
55
+
56
+ The `emails` search lets users find people who have an email address, optionally filtered by:
57
+
58
+ - `subscriptionStatus`, using the same values as `subscription_status`
59
+ - `emailMatch`, matched against the `email` column with `LIKE`
60
+
61
+ ## Segments
62
+
63
+ Predefined audiences shipped with this schema plugin. On install, each key becomes a `segment` row whose definition path is `@engine9/schemas/person_email:segments:<key>`.
64
+
65
+ ### Email Subscribers
66
+
67
+ | | |
68
+ | --- | --- |
69
+ | **Key** | `subscribers` |
70
+ | **Definition path** | `@engine9/schemas/person_email:segments:subscribers` |
71
+ | **Who is included** | People with at least one email whose `subscription_status` is `Subscribed` |
72
+ | **Who is excluded** | People whose emails are only `Not Subscribed`, `Unsubscribed`, `Bouncing`, or `Spam` |
73
+ | **How it is built** | The `emails` search with `subscriptionStatus: Subscribed` |
74
+ | **Universe** | None — membership is current `person_email` rows, not a time window or message input set |
75
+
76
+ A person with mixed statuses is included if any of their addresses is `Subscribed`.
77
+
78
+ ## Reports and UI
79
+
80
+ Email subscription dashboards live on `@engine9/plugins/reports/people` (`subscription_status`). See [`plugins/reports/people/README.md`](../../plugins/reports/people/README.md).
81
+
82
+ The console UI adds an `Emails` tab on person records. It displays each person's email rows with the email address, type, subscription status, and modified timestamp.
@@ -0,0 +1,35 @@
1
+ import schema from './schema.js';
2
+ import extractEmailHashes from './transforms/inbound/extract_identifiers.js';
3
+ import upsertPersonEmail from './transforms/inbound/upsert_tables.js';
4
+ import search from './search.js';
5
+ import appendEmail from './transforms/outbound/appendEmail.js';
6
+ import appendEmailHash from './transforms/outbound/appendEmailHash.js';
7
+ import segments from './segments.js';
8
+ const metadata = {
9
+ name: '@engine9/schemas/person_email',
10
+ dependencies: {
11
+ '@engine9/schemas/person': '>=1.9.0'
12
+ },
13
+ // Inbound people pipeline slots -> transform export keys (woven by core when installed)
14
+ inbound: {
15
+ id: ['extractEmailHashes'],
16
+ upsert: ['upsertPersonEmail']
17
+ }
18
+ };
19
+ export const transforms = {
20
+ extractEmailHashes,
21
+ upsertPersonEmail,
22
+ appendEmail,
23
+ appendEmailHash
24
+ };
25
+ export { metadata };
26
+ export { schema };
27
+ export { search };
28
+ export { segments };
29
+ export default {
30
+ metadata,
31
+ schema,
32
+ search,
33
+ segments,
34
+ transforms
35
+ };
@@ -0,0 +1,49 @@
1
+ export const tables = [
2
+ {
3
+ name: 'person_email',
4
+ columns: {
5
+ id: 'id',
6
+ person_id: 'person_id',
7
+ email_type: {
8
+ type: 'string',
9
+ nullable: false,
10
+ default_value: 'Personal',
11
+ values: ['', 'Personal', 'Work', 'Other']
12
+ },
13
+ email: 'string',
14
+ subscription_status: {
15
+ type: 'string',
16
+ nullable: false,
17
+ default_value: 'Not Subscribed',
18
+ values: ['Not Subscribed', 'Subscribed', 'Unsubscribed', 'Bouncing', 'Spam']
19
+ },
20
+ confirmation_status: {
21
+ type: 'string',
22
+ nullable: true,
23
+ default_value: 'Not Confirmed',
24
+ values: ['Not Confirmed', 'Confirmation Sent', 'Confirmed']
25
+ },
26
+ deliverability_score: {
27
+ type: 'int',
28
+ description:
29
+ 'Score representating deliverability status of email, e.g. undeliverable(0), deliverable(1), higher values mean different things',
30
+ nullable: false,
31
+ default_value: 1
32
+ },
33
+ preference_order: {
34
+ type: 'int',
35
+ description: 'Order in the preference stack, 0 is first',
36
+ nullable: false,
37
+ default_value: 0
38
+ },
39
+ email_hash_v1: 'hash',
40
+ source_input_id: 'foreign_uuid',
41
+ created_at: 'created_at',
42
+ modified_at: 'modified_at'
43
+ },
44
+ indexes: [{ columns: 'person_id' }, { columns: ['email', 'person_id'], unique: true }]
45
+ }
46
+ ];
47
+ export default {
48
+ tables
49
+ };
@@ -0,0 +1,61 @@
1
+ export const emails = {
2
+ title: 'Emails',
3
+ form: {
4
+ title: 'Emails',
5
+ type: 'object',
6
+ properties: {
7
+ emailMatch: {
8
+ type: 'string'
9
+ },
10
+ subscriptionStatus: {
11
+ type: 'string',
12
+ enum: ['Not Subscribed', 'Subscribed', 'Unsubscribed', 'Bouncing', 'Spam']
13
+ }
14
+ },
15
+ required: []
16
+ },
17
+ /* map from provided user data into an EQL structure */
18
+ optionsToEQL(options) {
19
+ const { emailMatch, subscriptionStatus } = options;
20
+ let text = 'Has an email';
21
+ const conditions = [];
22
+ if (subscriptionStatus) {
23
+ text = `Has a ${subscriptionStatus.toLowerCase()} email`;
24
+ conditions.push({
25
+ type: 'EQUALS',
26
+ values: [
27
+ {
28
+ ref: { column: 'subscription_status' }
29
+ },
30
+ {
31
+ value: { value: subscriptionStatus }
32
+ }
33
+ ]
34
+ });
35
+ }
36
+ if (emailMatch) {
37
+ text += ` that matches ${emailMatch}`;
38
+ conditions.push({
39
+ type: 'LIKE',
40
+ values: [
41
+ {
42
+ ref: { column: 'email' }
43
+ },
44
+ {
45
+ value: { value: emailMatch }
46
+ }
47
+ ]
48
+ });
49
+ }
50
+ return {
51
+ text,
52
+ eql: {
53
+ table: 'person_email',
54
+ conditions
55
+ }
56
+ };
57
+ }
58
+ };
59
+ export default {
60
+ emails
61
+ };
@@ -0,0 +1,16 @@
1
+ export const subscribers = {
2
+ name: 'Email Subscribers',
3
+ search: {
4
+ and: [
5
+ {
6
+ path: '@engine9/schemas/person_email:search:emails',
7
+ options: {
8
+ subscriptionStatus: 'Subscribed'
9
+ }
10
+ }
11
+ ]
12
+ }
13
+ };
14
+ export default {
15
+ subscribers
16
+ };
@@ -0,0 +1,33 @@
1
+ import nodecrypto from 'node:crypto';
2
+ const { createHash } = nodecrypto;
3
+ export const type = 'id';
4
+ const blankHash = createHash('sha256').update('').digest('hex');
5
+ export async function transform({ batch }) {
6
+ batch.forEach((e) => {
7
+ e.identifiers = e.identifiers || [];
8
+ if (e.email && e.email.trim().length >= 5) {
9
+ e.email = e.email.trim(); // no spaces in emails
10
+ const hashable = e.email.toLowerCase();
11
+ // no secret or createHmac for this use case
12
+ // it's basically a shared id hashing setup, so a secret will be exposed anyhow
13
+ const value = createHash('sha256').update(hashable).digest('hex');
14
+ // Push all identifiers, later in the pipeline the priority will be determined
15
+ e.identifiers.push({
16
+ path: 'person_email',
17
+ type: 'email_hash_v1',
18
+ value
19
+ });
20
+ // e.email_hash_v1 = value;
21
+ } else if (e.email_hash_v1 && e.email_hash_v1 != blankHash) {
22
+ e.identifiers.push({
23
+ path: 'person_email',
24
+ type: 'email_hash_v1',
25
+ value: e.email_hash_v1
26
+ });
27
+ }
28
+ });
29
+ }
30
+ export default {
31
+ type,
32
+ transform
33
+ };
@@ -0,0 +1,142 @@
1
+ import { mergeIntoQueue } from '@engine9/input-tools';
2
+
3
+ export const bindings = {
4
+ // existingPersonEmails: { path: 'sql.query', table: 'person_email', lookup:['person_id'] },
5
+ databaseEmails: { path: 'sql.query', options: { table: 'person_email', lookup: ['email'] } },
6
+ tablesToUpsert: { path: 'sql.tables.upsert' }
7
+ };
8
+ export const type = 'upsert';
9
+
10
+ const emailKeyNormalize = (field, value) => {
11
+ if (field === 'email' && typeof value === 'string') return value.trim().toLowerCase();
12
+ // person_id may arrive as number or string from different pipeline stages
13
+ if (field === 'person_id' && value != null && value !== '') return String(value);
14
+ return value ?? '';
15
+ };
16
+
17
+ /** Last queued batch row wins on subscription_status (file order). */
18
+ function mergePersonEmail(existing, incoming) {
19
+ return {
20
+ ...existing,
21
+ ...incoming,
22
+ id: existing.id ?? incoming.id ?? null,
23
+ email: existing.email || incoming.email,
24
+ person_id: existing.person_id ?? incoming.person_id,
25
+ subscription_status: incoming.subscription_status ?? existing.subscription_status,
26
+ // `person_email.email_hash_v1` is not nullable in MySQL; use blank string when unknown.
27
+ // Also, never drop the key entirely (upsertArray column inference depends on key presence).
28
+ email_hash_v1: incoming.email_hash_v1 ?? existing.email_hash_v1 ?? '',
29
+ source_input_id: existing.source_input_id ?? incoming.source_input_id
30
+ };
31
+ }
32
+
33
+ function queuePersonEmail(tablesToUpsert, row, { keyFields = ['email', 'person_id'], key } = {}) {
34
+ tablesToUpsert.person_email = tablesToUpsert.person_email || [];
35
+ // Ensure the key is present for every queued object so core upsertArray's
36
+ // "inconsistent upsert columns" check never trips.
37
+ // `type` is the old field name for `email_type`. Every queued row needs the
38
+ // same keys; a batch that mixes the two names fails the upsert column check.
39
+ const normalizedRow = {
40
+ ...row,
41
+ email_hash_v1: row.email_hash_v1 ?? '',
42
+ email_type: row.email_type ?? row.type ?? 'Personal'
43
+ };
44
+ delete normalizedRow.type;
45
+ mergeIntoQueue(tablesToUpsert.person_email, normalizedRow, {
46
+ keyFields: key ? undefined : keyFields,
47
+ key,
48
+ normalizeField: key ? undefined : emailKeyNormalize,
49
+ merge: mergePersonEmail,
50
+ label: 'person_email'
51
+ });
52
+ }
53
+
54
+ export async function transform(props) {
55
+ const { batch, databaseEmails, tablesToUpsert } = props;
56
+ if (batch.length === 0) return;
57
+ batch.forEach((o) => {
58
+ if (!o.email) return;
59
+ // People like to believe email is case sensitive
60
+ // emails are always trimmed, but that's it for inbound modifications.
61
+ const email = o.email.trim();
62
+ const lcEmail = email.toLowerCase();
63
+ const matchingEmails = databaseEmails.filter((em) => em?.email?.trim().toLowerCase() === lcEmail);
64
+ const personEmails = matchingEmails.filter((em) => o.person_id && em.person_id === o.person_id);
65
+ if (personEmails.length > 1) {
66
+ throw new Error(
67
+ `Cannot update emails, there are 2 database entries for person_id ${o.person_id} with email ${email}`
68
+ );
69
+ }
70
+ if (
71
+ o.entry_type === 'EMAIL_UNSUBSCRIBE' ||
72
+ o.entry_type === 'EMAIL_SPAM' ||
73
+ o.entry_type_id === 44 ||
74
+ o.entry_type_id === 48
75
+ ) {
76
+ if (!o.subscription_status) o.subscription_status = 'Unsubscribed';
77
+ }
78
+ let status = o.email_subscription_status || o.subscription_status;
79
+ const { id, ...rest } = o;
80
+ if (id) {
81
+ //this is undoubtedly NOT the ID of the person_email record
82
+ }
83
+ if (personEmails[0]) {
84
+ // if it's explicitly specified, then update it, otherwise set it to what it was before
85
+ status = status || personEmails[0].subscription_status;
86
+ queuePersonEmail(tablesToUpsert, {
87
+ ...rest,
88
+ id: personEmails[0].id,
89
+ person_id: personEmails[0].person_id,
90
+ email,
91
+ email_type: o.email_type ?? o.type ?? personEmails[0].email_type ?? 'Personal',
92
+ subscription_status: status,
93
+ //make sure this doesn't change
94
+ source_input_id: personEmails[0].source_input_id
95
+ //original: personEmails[0]
96
+ });
97
+ } else {
98
+ status = status || 'Subscribed'; // Default subscribed
99
+ queuePersonEmail(tablesToUpsert, {
100
+ ...rest,
101
+ id: null,
102
+ person_id: o.person_id,
103
+ email,
104
+ subscription_status: status,
105
+ source_input_id: o.input_id
106
+ });
107
+ }
108
+ // IF an unsubscribe, we need to update the subscription status for ALL related emails,
109
+ // including new ones
110
+ if (status === 'Unsubscribed') {
111
+ matchingEmails.forEach((original) => {
112
+ if (original.subscription_status !== 'Unsubscribed') {
113
+ let updatedRecord = {};
114
+ //this is to make sure the keys match
115
+ Object.keys(rest).forEach((k) => {
116
+ updatedRecord[k] = original[k] ?? null;
117
+ });
118
+ Object.assign(updatedRecord, {
119
+ id: original.id,
120
+ person_id: original.person_id,
121
+ email,
122
+ email_type: o.email_type ?? o.type ?? original.email_type ?? 'Personal',
123
+ subscription_status: 'Unsubscribed',
124
+ //make sure this doesn't change
125
+ source_input_id: original.source_input_id
126
+ });
127
+ queuePersonEmail(tablesToUpsert, updatedRecord, { key: (row) => String(row.id) });
128
+ }
129
+ });
130
+ tablesToUpsert.person_email
131
+ .filter((d) => d.id == null && d.email?.trim().toLowerCase() === lcEmail)
132
+ .forEach((d) => {
133
+ d.subscription_status = 'Unsubscribed';
134
+ });
135
+ }
136
+ });
137
+ }
138
+ export default {
139
+ bindings,
140
+ type,
141
+ transform
142
+ };
@@ -0,0 +1,32 @@
1
+ export const description = 'Append an email';
2
+ export const bindings = {
3
+ emails: {
4
+ path: 'sql.query',
5
+ options: {
6
+ table: 'person_email',
7
+ columns: ['person_id', 'email', 'subscription_status'],
8
+ lookup: ['person_id'],
9
+ conditions: [
10
+ // { eql: 'id_type=\'remote_person_id\'' },
11
+ ]
12
+ }
13
+ }
14
+ };
15
+ export const transform = ({ batch, emails, options = {} }) => {
16
+ const { subscriptionStatus } = options;
17
+ let filter = () => true;
18
+ if (subscriptionStatus) filter = (d) => d.subscription_status === subscriptionStatus;
19
+ const emailMap = emails.filter(filter).reduce((a, b) => {
20
+ a[b.person_id] = a[b.person_id] || b.email;
21
+ return a;
22
+ }, {});
23
+ batch.forEach((data) => {
24
+ // keep existing email if there is one
25
+ data.email = data.email || emailMap[data.person_id] || null;
26
+ });
27
+ };
28
+ export default {
29
+ description,
30
+ bindings,
31
+ transform
32
+ };
@@ -0,0 +1,20 @@
1
+ import nodecrypto from 'node:crypto';
2
+ const { createHash } = nodecrypto;
3
+ export async function transform({ batch }) {
4
+ batch.forEach((e) => {
5
+ e.email_hash_v1 = e.email_hash_v1 || '';
6
+ if (e.email_hash_v1) {
7
+ // do nothing, already exists
8
+ } else if (e.email) {
9
+ e.email = e.email.trim(); // no spaces in emails
10
+ const hashable = e.email.toLowerCase();
11
+ // no secret or createHmac for this use case
12
+ // it's basically a shared id hashing setup, so a secret will be exposed anyhow
13
+ const value = createHash('sha256').update(hashable).digest('hex');
14
+ e.email_hash_v1 = value;
15
+ }
16
+ });
17
+ }
18
+ export default {
19
+ transform
20
+ };