@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,86 @@
1
+ function hashCondition(column, value) {
2
+ return {
3
+ type: 'EQUALS',
4
+ values: [
5
+ { ref: { column } },
6
+ { value: { value } }
7
+ ]
8
+ };
9
+ }
10
+
11
+ function hashColumnForValue(value, shaColumn, md5Column) {
12
+ const hex = String(value || '').trim().toLowerCase();
13
+ if (hex.length === 32) return md5Column;
14
+ return shaColumn;
15
+ }
16
+
17
+ export const emailHashes = {
18
+ title: 'Email hashes',
19
+ form: {
20
+ title: 'Email hashes',
21
+ type: 'object',
22
+ properties: {
23
+ emailHash: {
24
+ title: 'Email hash (SHA-256 or MD5 hex)',
25
+ type: 'string'
26
+ }
27
+ },
28
+ required: []
29
+ },
30
+ optionsToEQL(options) {
31
+ const { emailHash } = options;
32
+ const conditions = [];
33
+ let text = 'Has an email hash';
34
+ if (emailHash) {
35
+ const column = hashColumnForValue(emailHash, 'email_hash_v1', 'email_hash_md5');
36
+ conditions.push(hashCondition(column, String(emailHash).trim().toLowerCase()));
37
+ text = `Has email hash ${emailHash}`;
38
+ }
39
+ return {
40
+ text,
41
+ eql: {
42
+ table: 'person_hash_email',
43
+ columns: ['person_id'],
44
+ conditions
45
+ }
46
+ };
47
+ }
48
+ };
49
+
50
+ export const phoneHashes = {
51
+ title: 'Phone hashes',
52
+ form: {
53
+ title: 'Phone hashes',
54
+ type: 'object',
55
+ properties: {
56
+ phoneHash: {
57
+ title: 'Phone hash (SHA-256 or MD5 hex)',
58
+ type: 'string'
59
+ }
60
+ },
61
+ required: []
62
+ },
63
+ optionsToEQL(options) {
64
+ const { phoneHash } = options;
65
+ const conditions = [];
66
+ let text = 'Has a phone hash';
67
+ if (phoneHash) {
68
+ const column = hashColumnForValue(phoneHash, 'phone_hash_v1', 'phone_hash_md5');
69
+ conditions.push(hashCondition(column, String(phoneHash).trim().toLowerCase()));
70
+ text = `Has phone hash ${phoneHash}`;
71
+ }
72
+ return {
73
+ text,
74
+ eql: {
75
+ table: 'person_hash_phone',
76
+ columns: ['person_id'],
77
+ conditions
78
+ }
79
+ };
80
+ }
81
+ };
82
+
83
+ export default {
84
+ emailHashes,
85
+ phoneHashes
86
+ };
@@ -0,0 +1,70 @@
1
+ import {
2
+ hashEmail,
3
+ hashPhone,
4
+ isBlankSha256,
5
+ resolvePhoneFromRow
6
+ } from '../../hash.js';
7
+
8
+ export const type = 'id';
9
+ export const description =
10
+ 'Extract email_hash_v1 / phone_hash_v1 identifiers from plaintext or existing hashes. Does not persist email or phone.';
11
+
12
+ function pushIdentifier(row, path, typeName, value) {
13
+ if (!value || isBlankSha256(value)) return;
14
+ row.identifiers = row.identifiers || [];
15
+ if (row.identifiers.some((id) => id.type === typeName && id.value === value)) return;
16
+ row.identifiers.push({ path, type: typeName, value });
17
+ }
18
+
19
+ function applyEmailHashes(row) {
20
+ const fromEmail = hashEmail(row.email);
21
+ if (fromEmail) {
22
+ row.email = String(row.email).trim();
23
+ row.email_hash_v1 = fromEmail.email_hash_v1;
24
+ row.email_hash_md5 = fromEmail.email_hash_md5;
25
+ pushIdentifier(row, 'person_hash_email', 'email_hash_v1', fromEmail.email_hash_v1);
26
+ return;
27
+ }
28
+ if (row.email_hash_v1 && !isBlankSha256(row.email_hash_v1)) {
29
+ pushIdentifier(row, 'person_hash_email', 'email_hash_v1', row.email_hash_v1);
30
+ }
31
+ }
32
+
33
+ function applyPhoneHashes(row) {
34
+ const rawPhone = resolvePhoneFromRow(row);
35
+ if (!row.phone && rawPhone) {
36
+ row.phone = rawPhone;
37
+ row.phone_type = row.phone_type || 'Cell';
38
+ }
39
+ const fromPhone = hashPhone(row.phone);
40
+ if (fromPhone) {
41
+ row.phone = fromPhone.phone;
42
+ row.phone_hash_v1 = fromPhone.phone_hash_v1;
43
+ row.phone_hash_md5 = fromPhone.phone_hash_md5;
44
+ pushIdentifier(row, 'person_hash_phone', 'phone_hash_v1', fromPhone.phone_hash_v1);
45
+ return;
46
+ }
47
+ if (row.phone_hash_v1 && !isBlankSha256(row.phone_hash_v1)) {
48
+ pushIdentifier(row, 'person_hash_phone', 'phone_hash_v1', row.phone_hash_v1);
49
+ return;
50
+ }
51
+ if (row.phone && String(row.phone).replace(/[^0-9+]*/g, '').trim().length > 0) {
52
+ // Too short to hash — drop plaintext so it cannot be stored downstream.
53
+ row.phone = null;
54
+ row.phone_hash_v1 = null;
55
+ row.phone_hash_md5 = null;
56
+ }
57
+ }
58
+
59
+ export async function transform({ batch }) {
60
+ batch.forEach((row) => {
61
+ applyEmailHashes(row);
62
+ applyPhoneHashes(row);
63
+ });
64
+ }
65
+
66
+ export default {
67
+ type,
68
+ description,
69
+ transform
70
+ };
@@ -0,0 +1,105 @@
1
+ import { mergeIntoQueue } from '@engine9/input-tools';
2
+ import { isBlankMd5, isBlankSha256 } from '../../hash.js';
3
+
4
+ export const type = 'upsert';
5
+ export const description =
6
+ 'Upsert person_hash_email / person_hash_phone from hashes on the batch. Never writes email or phone plaintext.';
7
+
8
+ export const bindings = {
9
+ databaseEmailHashes: {
10
+ path: 'sql.query',
11
+ options: { table: 'person_hash_email', lookup: ['email_hash_v1'] }
12
+ },
13
+ databasePhoneHashes: {
14
+ path: 'sql.query',
15
+ options: { table: 'person_hash_phone', lookup: ['phone_hash_v1'] }
16
+ },
17
+ tablesToUpsert: { path: 'sql.tables.upsert' }
18
+ };
19
+
20
+ function usableSha256(value) {
21
+ return Boolean(value) && !isBlankSha256(value);
22
+ }
23
+
24
+ function usableMd5(value) {
25
+ if (value == null || value === '') return '';
26
+ if (isBlankMd5(value)) return '';
27
+ return value;
28
+ }
29
+
30
+ function findExisting(rows, personId, hashColumn, hashValue) {
31
+ const matching = rows.filter((d) => d[hashColumn] === hashValue);
32
+ const forPerson = matching.filter((d) => personId && d.person_id === personId);
33
+ if (forPerson.length > 1) {
34
+ throw new Error(
35
+ `Cannot update ${hashColumn}, there are ${forPerson.length} database entries for person_id ${personId}`
36
+ );
37
+ }
38
+ return forPerson[0] || null;
39
+ }
40
+
41
+ function mergeHashRow(existing, incoming, md5Column) {
42
+ return {
43
+ ...existing,
44
+ ...incoming,
45
+ [md5Column]: incoming[md5Column] || existing[md5Column] || '',
46
+ source_input_id: existing.source_input_id ?? incoming.source_input_id
47
+ };
48
+ }
49
+
50
+ function pushHashRow(tablesToUpsert, table, existing, { person_id, hashColumn, hashValue, md5Column, md5Value, input_id }) {
51
+ const md5 = md5Value || existing?.[md5Column] || '';
52
+ tablesToUpsert[table] = tablesToUpsert[table] || [];
53
+ mergeIntoQueue(
54
+ tablesToUpsert[table],
55
+ {
56
+ person_id: existing?.person_id || person_id,
57
+ [hashColumn]: hashValue,
58
+ [md5Column]: md5,
59
+ source_input_id: existing?.source_input_id || input_id
60
+ },
61
+ {
62
+ keyFields: ['person_id', hashColumn],
63
+ merge: (a, b) => mergeHashRow(a, b, md5Column),
64
+ label: table
65
+ }
66
+ );
67
+ }
68
+
69
+ export async function transform({ batch, databaseEmailHashes, databasePhoneHashes, tablesToUpsert }) {
70
+ if (batch.length === 0) return;
71
+ const emailRows = databaseEmailHashes || [];
72
+ const phoneRows = databasePhoneHashes || [];
73
+ batch.forEach((row) => {
74
+ if (!row.person_id) return;
75
+ if (usableSha256(row.email_hash_v1)) {
76
+ const existing = findExisting(emailRows, row.person_id, 'email_hash_v1', row.email_hash_v1);
77
+ pushHashRow(tablesToUpsert, 'person_hash_email', existing, {
78
+ person_id: row.person_id,
79
+ hashColumn: 'email_hash_v1',
80
+ hashValue: row.email_hash_v1,
81
+ md5Column: 'email_hash_md5',
82
+ md5Value: usableMd5(row.email_hash_md5),
83
+ input_id: row.input_id
84
+ });
85
+ }
86
+ if (usableSha256(row.phone_hash_v1)) {
87
+ const existing = findExisting(phoneRows, row.person_id, 'phone_hash_v1', row.phone_hash_v1);
88
+ pushHashRow(tablesToUpsert, 'person_hash_phone', existing, {
89
+ person_id: row.person_id,
90
+ hashColumn: 'phone_hash_v1',
91
+ hashValue: row.phone_hash_v1,
92
+ md5Column: 'phone_hash_md5',
93
+ md5Value: usableMd5(row.phone_hash_md5),
94
+ input_id: row.input_id
95
+ });
96
+ }
97
+ });
98
+ }
99
+
100
+ export default {
101
+ bindings,
102
+ type,
103
+ description,
104
+ transform
105
+ };
@@ -0,0 +1,42 @@
1
+ import { hashEmail } from '../../hash.js';
2
+
3
+ export const description =
4
+ 'Append email_hash_v1 / email_hash_md5 from plaintext email or person_hash_email. Does not append email.';
5
+
6
+ export const bindings = {
7
+ hashes: {
8
+ path: 'sql.query',
9
+ options: {
10
+ table: 'person_hash_email',
11
+ columns: ['person_id', 'email_hash_v1', 'email_hash_md5'],
12
+ lookup: ['person_id']
13
+ }
14
+ }
15
+ };
16
+
17
+ export const transform = ({ batch, hashes = [] }) => {
18
+ const byPerson = {};
19
+ hashes.forEach((row) => {
20
+ if (!byPerson[row.person_id]) byPerson[row.person_id] = row;
21
+ });
22
+ batch.forEach((row) => {
23
+ if (!row.email_hash_v1) {
24
+ const fromEmail = hashEmail(row.email);
25
+ if (fromEmail) {
26
+ row.email_hash_v1 = fromEmail.email_hash_v1;
27
+ row.email_hash_md5 = row.email_hash_md5 || fromEmail.email_hash_md5;
28
+ }
29
+ }
30
+ const stored = byPerson[row.person_id];
31
+ if (stored) {
32
+ row.email_hash_v1 = row.email_hash_v1 || stored.email_hash_v1 || null;
33
+ row.email_hash_md5 = row.email_hash_md5 || stored.email_hash_md5 || null;
34
+ }
35
+ });
36
+ };
37
+
38
+ export default {
39
+ description,
40
+ bindings,
41
+ transform
42
+ };
@@ -0,0 +1,42 @@
1
+ import { hashPhone } from '../../hash.js';
2
+
3
+ export const description =
4
+ 'Append phone_hash_v1 / phone_hash_md5 from plaintext phone or person_hash_phone. Does not append phone.';
5
+
6
+ export const bindings = {
7
+ hashes: {
8
+ path: 'sql.query',
9
+ options: {
10
+ table: 'person_hash_phone',
11
+ columns: ['person_id', 'phone_hash_v1', 'phone_hash_md5'],
12
+ lookup: ['person_id']
13
+ }
14
+ }
15
+ };
16
+
17
+ export const transform = ({ batch, hashes = [] }) => {
18
+ const byPerson = {};
19
+ hashes.forEach((row) => {
20
+ if (!byPerson[row.person_id]) byPerson[row.person_id] = row;
21
+ });
22
+ batch.forEach((row) => {
23
+ if (!row.phone_hash_v1) {
24
+ const fromPhone = hashPhone(row.phone);
25
+ if (fromPhone) {
26
+ row.phone_hash_v1 = fromPhone.phone_hash_v1;
27
+ row.phone_hash_md5 = row.phone_hash_md5 || fromPhone.phone_hash_md5;
28
+ }
29
+ }
30
+ const stored = byPerson[row.person_id];
31
+ if (stored) {
32
+ row.phone_hash_v1 = row.phone_hash_v1 || stored.phone_hash_v1 || null;
33
+ row.phone_hash_md5 = row.phone_hash_md5 || stored.phone_hash_md5 || null;
34
+ }
35
+ });
36
+ };
37
+
38
+ export default {
39
+ description,
40
+ bindings,
41
+ transform
42
+ };
@@ -0,0 +1,80 @@
1
+ {
2
+ routes: {
3
+ 'person/:id/*': {
4
+ components: {
5
+ sidebar: {
6
+ items: [
7
+ {
8
+ component: 'Link',
9
+ properties: {
10
+ label: 'Email hashes',
11
+ to: 'email_hashes'
12
+ }
13
+ },
14
+ {
15
+ component: 'Link',
16
+ properties: {
17
+ label: 'Phone hashes',
18
+ to: 'phone_hashes'
19
+ }
20
+ }
21
+ ]
22
+ },
23
+ main: [
24
+ {
25
+ path: 'email_hashes',
26
+ label: 'Email hashes',
27
+ component: 'RecordTable',
28
+ properties: {
29
+ table: 'person_hash_email',
30
+ conditions: [{ eql: 'person_id={{id}}' }],
31
+ columns: [
32
+ {
33
+ title: 'SHA-256',
34
+ template: '{{email_hash_v1}}',
35
+ width: '45%'
36
+ },
37
+ {
38
+ title: 'MD5',
39
+ template: '{{email_hash_md5}}',
40
+ width: '35%'
41
+ },
42
+ {
43
+ title: 'Modified',
44
+ template: '{{date modified_at}}',
45
+ width: '20%'
46
+ }
47
+ ]
48
+ }
49
+ },
50
+ {
51
+ path: 'phone_hashes',
52
+ label: 'Phone hashes',
53
+ component: 'RecordTable',
54
+ properties: {
55
+ table: 'person_hash_phone',
56
+ conditions: [{ eql: 'person_id={{id}}' }],
57
+ columns: [
58
+ {
59
+ title: 'SHA-256',
60
+ template: '{{phone_hash_v1}}',
61
+ width: '45%'
62
+ },
63
+ {
64
+ title: 'MD5',
65
+ template: '{{phone_hash_md5}}',
66
+ width: '35%'
67
+ },
68
+ {
69
+ title: 'Modified',
70
+ template: '{{date modified_at}}',
71
+ width: '20%'
72
+ }
73
+ ]
74
+ }
75
+ }
76
+ ]
77
+ }
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,79 @@
1
+ # Person Phone Schema Plugin
2
+
3
+ The `@engine9/schemas/person_phone` plugin stores phone numbers for people and provides the common phone-related behavior used by imports, matching, searches, segments, and exports.
4
+
5
+ It depends on `@engine9/schemas/person` because every phone row belongs to a person through `person_id`.
6
+
7
+ ## Data Model
8
+
9
+ The plugin creates the `person_phone` table. A person can have multiple phone rows, but each `(phone, person_id)` pair is unique.
10
+
11
+ | Field | Type | Description |
12
+ | --- | --- | --- |
13
+ | `id` | `id` | Primary row identifier. |
14
+ | `person_id` | `person_id` | Person that owns the phone number. |
15
+ | `phone_type` | `string` | Phone category. Allowed values are `Personal`, `Cell`, `Home`, `Work`, `Fax`, and `Other`. Defaults to `Personal`. |
16
+ | `phone` | `string` | Phone number. Inbound transforms keep digits and `+`, and prefix US 10-digit numbers with `+1`. |
17
+ | `preference_order` | `int` | Sort order for preferred phones, where `0` is first. Defaults to `0`. |
18
+ | `sms_status` | `string` | SMS subscription state. Allowed values are `Not Subscribed`, `Subscribed`, `Unsubscribed`, and `Bouncing`. Defaults to `Not Subscribed`. |
19
+ | `sms_deliverability_score` | `int` | SMS deliverability score on a 1–100 scale (`100` is most deliverable). Defaults to `100`. |
20
+ | `call_status` | `string` | Voice-call subscription state. Same allowed values as `sms_status`. Defaults to `Not Subscribed`. |
21
+ | `remote_phone_id` | `string` | Remote system phone record id when available. |
22
+ | `phone_hash_v1` | `hash` | SHA-256 hash of the normalized phone number. Used as an identifier and for privacy-preserving exports. |
23
+ | `source_input_id` | `foreign_uuid` | Input that originally created the phone row. Existing rows keep their original source when updated. |
24
+ | `created_at` | `created_at` | Timestamp when the row was created. |
25
+ | `modified_at` | `modified_at` | Timestamp when the row was last modified. |
26
+
27
+ Indexes:
28
+
29
+ - `person_id`
30
+ - unique `(phone, person_id)`
31
+ - `remote_phone_id`
32
+
33
+ ## Inbound Behavior
34
+
35
+ The inbound `id` transform extracts a `phone_hash_v1` identifier from either `phone` or an existing `phone_hash_v1` field.
36
+
37
+ - `cell`, `mobile`, and `mobile_phone` fill `phone` when it is empty, and default `phone_type` to `Cell`.
38
+ - A bare `phone` with no type defaults to `Home`.
39
+ - Numbers shorter than 8 digits after cleaning are dropped.
40
+ - US numbers without a `+` prefix are normalized (`10` digits become `+1…`; `11` digits starting with `1` become `+1…`).
41
+
42
+ The inbound `upsert` transform writes rows to `person_phone`.
43
+
44
+ - Existing rows for the same `person_id` and phone are updated instead of duplicated.
45
+ - Newly inserted phones default to `sms_status` `Not Subscribed` when no status is provided.
46
+ - SMS unsubscribe-like entries set `sms_status` to `Unsubscribed` when no status was provided. This includes `entry_type` values `SMS_UNSUBSCRIBE` and `SMS_SPAM`, plus `entry_type_id` values `34` and `38`.
47
+
48
+ ## Outbound Behavior
49
+
50
+ `appendPhoneHash` hashes the row's normalized `phone` into `phone_hash_v1` when the hash is not already present.
51
+
52
+ ## Search
53
+
54
+ The `phones` search lets users find people who have a phone number, optionally filtered by:
55
+
56
+ - `smsStatus`, using the same values as `sms_status`
57
+ - `callStatus`, using the same values as `call_status`
58
+ - `phoneMatch`, matched against the `phone` column with `LIKE`
59
+
60
+ ## Segments
61
+
62
+ Predefined audiences shipped with this schema plugin. On install, each key becomes a `segment` row whose definition path is `@engine9/schemas/person_phone:segments:<key>`.
63
+
64
+ ### Textable People
65
+
66
+ | | |
67
+ | --- | --- |
68
+ | **Key** | `textable` |
69
+ | **Definition path** | `@engine9/schemas/person_phone:segments:textable` |
70
+ | **Who is included** | People with at least one phone whose `sms_status` is `Subscribed` |
71
+ | **Who is excluded** | People whose phones are only `Not Subscribed`, `Unsubscribed`, or `Bouncing` for SMS |
72
+ | **How it is built** | Direct `person_phone` condition `sms_status = Subscribed` |
73
+ | **Universe** | None — membership is current `person_phone` rows, not a time window or message input set |
74
+
75
+ `call_status` is not part of this segment. A person can be textable without being subscribed for voice calls.
76
+
77
+ ## UI
78
+
79
+ The console UI adds a `Phones` tab on person records. It displays each person's phone rows with the number and type.
@@ -0,0 +1,33 @@
1
+ import schema from './schema.js';
2
+ import search from './search.js';
3
+ import segments from './segments.js';
4
+ import extractPhoneHashes from './transforms/inbound/extract_identifiers.js';
5
+ import upsertPersonPhone from './transforms/inbound/upsert_tables.js';
6
+ import appendPhoneHash from './transforms/outbound/appendPhoneHash.js';
7
+ const metadata = {
8
+ name: '@engine9/schemas/person_phone',
9
+ dependencies: {
10
+ '@engine9/schemas/person': '>=1.9.0'
11
+ },
12
+ // Inbound people pipeline slots -> transform export keys (woven by core when installed)
13
+ inbound: {
14
+ id: ['extractPhoneHashes'],
15
+ upsert: ['upsertPersonPhone']
16
+ }
17
+ };
18
+ export const transforms = {
19
+ extractPhoneHashes,
20
+ upsertPersonPhone,
21
+ appendPhoneHash
22
+ };
23
+ export { metadata };
24
+ export { schema };
25
+ export { search };
26
+ export { segments };
27
+ export default {
28
+ metadata,
29
+ schema,
30
+ transforms,
31
+ search,
32
+ segments
33
+ };
@@ -0,0 +1,58 @@
1
+ export const tables = [
2
+ {
3
+ name: 'person_phone',
4
+ columns: {
5
+ id: 'id',
6
+ person_id: 'person_id',
7
+ phone_type: {
8
+ type: 'string',
9
+ nullable: false,
10
+ default_value: 'Personal',
11
+ values: ['', 'Personal', 'Cell', 'Home', 'Work', 'Fax', 'Other']
12
+ },
13
+ phone: 'string',
14
+ preference_order: {
15
+ type: 'int',
16
+ nullable: false,
17
+ default_value: 0,
18
+ description: 'Order in the preference stack, 0 is first'
19
+ },
20
+ sms_status: {
21
+ type: 'string',
22
+ nullable: false,
23
+ default_value: 'Not Subscribed',
24
+ values: ['Not Subscribed', 'Subscribed', 'Unsubscribed', 'Bouncing']
25
+ },
26
+ sms_deliverability_score: {
27
+ type: 'int',
28
+ description:
29
+ 'SMS deliverability score on a 1–100 scale (100 = most deliverable / verified cell). Use lower values for weaker confidence; 0 may appear from legacy sources meaning undeliverable.',
30
+ nullable: false,
31
+ default_value: 100
32
+ },
33
+ call_status: {
34
+ type: 'string',
35
+ nullable: false,
36
+ default_value: 'Not Subscribed',
37
+ values: ['Not Subscribed', 'Subscribed', 'Unsubscribed', 'Bouncing']
38
+ },
39
+ remote_phone_id: {
40
+ type: 'string',
41
+ nullable: true,
42
+ description: 'Remote system phone record id when available'
43
+ },
44
+ phone_hash_v1: 'hash',
45
+ source_input_id: 'foreign_uuid',
46
+ created_at: 'created_at',
47
+ modified_at: 'modified_at'
48
+ },
49
+ indexes: [
50
+ { columns: 'person_id' },
51
+ { columns: ['phone', 'person_id'], unique: true },
52
+ { columns: 'remote_phone_id' }
53
+ ]
54
+ }
55
+ ];
56
+ export default {
57
+ tables
58
+ };
@@ -0,0 +1,79 @@
1
+ export const phones = {
2
+ title: 'Phones',
3
+ form: {
4
+ title: 'Phones',
5
+ type: 'object',
6
+ properties: {
7
+ phoneMatch: {
8
+ type: 'string'
9
+ },
10
+ smsStatus: {
11
+ type: 'string',
12
+ enum: ['Not Subscribed', 'Subscribed', 'Unsubscribed', 'Bouncing']
13
+ },
14
+ callStatus: {
15
+ type: 'string',
16
+ enum: ['Not Subscribed', 'Subscribed', 'Unsubscribed', 'Bouncing']
17
+ }
18
+ },
19
+ required: []
20
+ },
21
+ /* map from provided user data into an EQL structure */
22
+ optionsToEQL(options) {
23
+ const { phoneMatch, smsStatus, callStatus } = options;
24
+ const conditions = [];
25
+ let text = [];
26
+ if (smsStatus) {
27
+ text.push(`A SMS ${smsStatus.toLowerCase()} phone`);
28
+ conditions.push({
29
+ type: 'EQUALS',
30
+ values: [
31
+ {
32
+ ref: { column: 'sms_status' }
33
+ },
34
+ {
35
+ value: { value: smsStatus }
36
+ }
37
+ ]
38
+ });
39
+ }
40
+ if (callStatus) {
41
+ text.push(`A voice ${callStatus.toLowerCase()} phone`);
42
+ conditions.push({
43
+ type: 'EQUALS',
44
+ values: [
45
+ {
46
+ ref: { column: 'call_status' }
47
+ },
48
+ {
49
+ value: { value: callStatus }
50
+ }
51
+ ]
52
+ });
53
+ }
54
+ if (phoneMatch) {
55
+ text.push(`A phone that matches ${phoneMatch}`);
56
+ conditions.push({
57
+ type: 'LIKE',
58
+ values: [
59
+ {
60
+ ref: { column: 'phone' }
61
+ },
62
+ {
63
+ value: { value: phoneMatch }
64
+ }
65
+ ]
66
+ });
67
+ }
68
+ return {
69
+ text,
70
+ eql: {
71
+ table: 'person_phone',
72
+ conditions
73
+ }
74
+ };
75
+ }
76
+ };
77
+ export default {
78
+ phones
79
+ };