@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,26 @@
1
+ export const textable = {
2
+ name: 'Textable People',
3
+ search: {
4
+ and: [
5
+ {
6
+ table: 'person_phone',
7
+ conditions: [
8
+ {
9
+ type: 'EQUALS',
10
+ values: [
11
+ {
12
+ ref: { column: 'sms_status' }
13
+ },
14
+ {
15
+ value: { value: 'Subscribed' }
16
+ }
17
+ ]
18
+ }
19
+ ]
20
+ }
21
+ ]
22
+ }
23
+ };
24
+ export default {
25
+ textable
26
+ };
@@ -0,0 +1,59 @@
1
+ import nodecrypto from 'node:crypto';
2
+ const { createHash } = nodecrypto;
3
+ export const type = 'id';
4
+ const blankHash = createHash('sha256').update('').digest('hex');
5
+ const MIN_PHONE_LENGTH = 8;
6
+ export async function transform({ batch }) {
7
+ const ids = [];
8
+ let hasPhoneHash = false;
9
+ batch.forEach((o) => {
10
+ o.identifiers = o.identifiers || [];
11
+ // Prefer mobile_phone / cell aliases when phone is empty
12
+ if (!o.phone && (o.cell || o.mobile || o.mobile_phone)) {
13
+ o.phone = o.cell || o.mobile || o.mobile_phone;
14
+ o.phone_type = o.phone_type || 'Cell';
15
+ } else if (o.phone && !o.phone_type) {
16
+ // Bare phone with no type defaults to Home (mobile should already be prioritized upstream)
17
+ o.phone_type = 'Home';
18
+ }
19
+
20
+ let phone = (o.phone || '').replace(/[^0-9+]*/g, '').trim();
21
+ // clean and add a plus
22
+ if (phone && phone.length >= MIN_PHONE_LENGTH) {
23
+ // clean us based phones. If it's already prefixed with '+' then assume
24
+ // it's intentional
25
+ if (phone.indexOf('+') < 0) {
26
+ if (phone.length === 10) phone = `+1${phone}`;
27
+ else if (phone.length === 11 && phone.slice(0, 1) === '1') phone = `+${phone}`;
28
+ }
29
+ // no secret or createHmac for this use case
30
+ // it's basically a shared id hashing setup, so a secret will be exposed anyhow
31
+ const value = createHash('sha256').update(phone).digest('hex');
32
+ // Push all identifiers, later in the pipeline the priority will be determined
33
+ o.identifiers.push({
34
+ path: 'person_phone',
35
+ type: 'phone_hash_v1',
36
+ value
37
+ });
38
+ o.phone = phone;
39
+ o.phone_hash_v1 = value;
40
+ hasPhoneHash = true;
41
+ } else if (phone && phone.length > 0 && phone.length < MIN_PHONE_LENGTH) {
42
+ o.phone = null;
43
+ o.phone_hash_v1 = null;
44
+ } else if (o.phone_hash_v1 && o.phone_hash_v1 != blankHash) {
45
+ o.identifiers.push({
46
+ path: 'person_phone',
47
+ type: 'phone_hash_v1',
48
+ value: o.phone_hash_v1
49
+ });
50
+ hasPhoneHash = true;
51
+ }
52
+ });
53
+ if (hasPhoneHash) batch.forEach((d) => (d.phone_hash_v1 = d.phone_hash_v1 || null));
54
+ return ids;
55
+ }
56
+ export default {
57
+ type,
58
+ transform
59
+ };
@@ -0,0 +1,119 @@
1
+ import { mergeIntoQueue } from '@engine9/input-tools';
2
+
3
+ export const type = 'upsert';
4
+ const MIN_PHONE_LENGTH = 8;
5
+ function cleanedPhoneLength(phone) {
6
+ return String(phone || '')
7
+ .replace(/[^0-9+]*/g, '')
8
+ .trim().length;
9
+ }
10
+ export const bindings = {
11
+ tablesToUpsert: { path: 'sql.tables.upsert' },
12
+ databasePhones: { path: 'sql.query', options: { table: 'person_phone', lookup: ['phone'] } }
13
+ };
14
+
15
+ function mergePersonPhone(existing, incoming) {
16
+ return {
17
+ ...existing,
18
+ ...incoming,
19
+ id: existing.id ?? incoming.id ?? null,
20
+ phone: existing.phone || incoming.phone,
21
+ person_id: existing.person_id ?? incoming.person_id,
22
+ source_input_id: existing.source_input_id ?? incoming.source_input_id,
23
+ // These columns can be optional in inbound payloads, and `upsertArray`
24
+ // is strict about consistent object-key presence across samples.
25
+ // Normalize them so every queued object has the same key set.
26
+ phone_hash_v1: incoming.phone_hash_v1 ?? existing.phone_hash_v1 ?? '',
27
+ remote_phone_id: incoming.remote_phone_id ?? existing.remote_phone_id ?? null,
28
+ sms_deliverability_score: incoming.sms_deliverability_score ?? existing.sms_deliverability_score ?? 100,
29
+ call_status: incoming.call_status ?? existing.call_status ?? 'Not Subscribed',
30
+ sms_status: incoming.sms_status ?? existing.sms_status,
31
+ phone_type: incoming.phone_type ?? existing.phone_type,
32
+ preference_order:
33
+ incoming.preference_order !== undefined && incoming.preference_order !== null
34
+ ? incoming.preference_order
35
+ : existing.preference_order
36
+ };
37
+ }
38
+
39
+ function queuePersonPhone(tablesToUpsert, row) {
40
+ tablesToUpsert.person_phone = tablesToUpsert.person_phone || [];
41
+ const normalizedRow = {
42
+ ...row,
43
+ phone_hash_v1: row.phone_hash_v1 ?? '',
44
+ remote_phone_id: row.remote_phone_id ?? null,
45
+ sms_deliverability_score: row.sms_deliverability_score ?? 100,
46
+ call_status: row.call_status ?? 'Not Subscribed'
47
+ };
48
+ mergeIntoQueue(tablesToUpsert.person_phone, normalizedRow, {
49
+ keyFields: ['phone', 'person_id'],
50
+ merge: mergePersonPhone,
51
+ label: 'person_phone'
52
+ });
53
+ }
54
+
55
+ export async function transform({ batch, tablesToUpsert, databasePhones }) {
56
+ batch.forEach((o) => {
57
+ if (!o.phone || cleanedPhoneLength(o.phone) < MIN_PHONE_LENGTH) return;
58
+ // phone should be already cleaned in extract ids
59
+ const matchingPhones = databasePhones.filter((d) => d.phone === o.phone);
60
+ const personPhones = matchingPhones.filter((em) => o.person_id && em.person_id === o.person_id);
61
+ if (personPhones.length > 1) {
62
+ throw new Error(
63
+ `Cannot update phone, there are 2 database entries for person_id ${o.person_id} with phone ${o.phone}`
64
+ );
65
+ }
66
+ if (
67
+ o.entry_type === 'SMS_UNSUBSCRIBE' ||
68
+ o.entry_type === 'SMS_SPAM' ||
69
+ o.entry_type_id === 34 ||
70
+ o.entry_type_id === 38
71
+ ) {
72
+ if (!o.sms_status) o.sms_status = 'Unsubscribed';
73
+ }
74
+ const sms_status = o.sms_status || personPhones[0]?.sms_status || 'Not Subscribed';
75
+ const phone_type = o.phone_type || personPhones[0]?.phone_type || 'Personal';
76
+ const preference_order =
77
+ o.preference_order !== undefined && o.preference_order !== null
78
+ ? o.preference_order
79
+ : (personPhones[0]?.preference_order ?? 0);
80
+
81
+ if (o.call_status !== undefined && !o.call_status) o.call_status = 'Not Subscribed';
82
+
83
+ const { id, ...rest } = o;
84
+ if (id) {
85
+ //this is undoubtedly NOT the ID of the record
86
+ }
87
+ if (personPhones[0]) {
88
+ if (!personPhones[0].source_input_id)
89
+ throw new Error('Invalid source_input_id for existing person_phone record:' + JSON.stringify(personPhones[0]));
90
+ queuePersonPhone(tablesToUpsert, {
91
+ ...rest,
92
+ id: personPhones[0].id,
93
+ person_id: personPhones[0].person_id,
94
+ phone: personPhones[0].phone,
95
+ source_input_id: personPhones[0].source_input_id,
96
+ sms_status,
97
+ phone_type,
98
+ preference_order
99
+ });
100
+ } else {
101
+ queuePersonPhone(tablesToUpsert, {
102
+ ...rest,
103
+ id: null,
104
+ person_id: o.person_id,
105
+ phone: o.phone,
106
+ source_input_id: o.input_id,
107
+ sms_status,
108
+ phone_type,
109
+ preference_order
110
+ });
111
+ }
112
+ });
113
+ return batch;
114
+ }
115
+ export default {
116
+ type,
117
+ bindings,
118
+ transform
119
+ };
@@ -0,0 +1,37 @@
1
+ import nodecrypto from 'node:crypto';
2
+ const { createHash } = nodecrypto;
3
+ export const type = 'id';
4
+ const MIN_PHONE_LENGTH = 8;
5
+ export async function transform({ batch }) {
6
+ const ids = [];
7
+ batch.forEach((e) => {
8
+ e.phone_hash_v1 = e.phone_hash_v1 || '';
9
+ if (e.phone_hash_v1) {
10
+ // do nothing
11
+ } else if (e.phone) {
12
+ // clean and add a plus
13
+ let phone = e.phone.replace(/[^0-9+]*/g, '').trim();
14
+ if (phone.length >= MIN_PHONE_LENGTH) {
15
+ // clean us based phones. If it's already prefixed with '+' then assume
16
+ // it's intentional
17
+ if (phone.indexOf('+') < 0) {
18
+ if (phone.length === 10) phone = `+1${phone}`;
19
+ else if (phone.length === 11 && phone.slice(0, 1) === '1') phone = `+${phone}`;
20
+ }
21
+ // no secret or createHmac for this use case
22
+ // it's basically a shared id hashing setup, so a secret will be exposed anyhow
23
+ const value = createHash('sha256').update(phone).digest('hex');
24
+ e.phone = phone;
25
+ e.phone_hash_v1 = value;
26
+ } else {
27
+ e.phone = null;
28
+ e.phone_hash_v1 = null;
29
+ }
30
+ }
31
+ });
32
+ return ids;
33
+ }
34
+ export default {
35
+ type,
36
+ transform
37
+ };
@@ -0,0 +1,31 @@
1
+ {
2
+ routes: {
3
+ 'person/:id/*': {
4
+ components: {
5
+ sidebar: {
6
+ items2: [
7
+ {
8
+ component: 'Link',
9
+ properties: {
10
+ label: 'Phones',
11
+ to: 'phones'
12
+ }
13
+ }
14
+ ]
15
+ },
16
+ main: [
17
+ {
18
+ path: 'phones',
19
+ label: 'Phones',
20
+ component: 'RecordDisplay',
21
+ properties: {
22
+ table: 'person_phone',
23
+ conditions: [{ eql: 'person_id={{person_id}}' }],
24
+ rows: ['{{phone}}', '{{phone_type}}']
25
+ }
26
+ }
27
+ ]
28
+ }
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import upsertPhone from './transforms/inbound/upsert_tables.js';
4
+
5
+ describe('person_phone upsert_tables', () => {
6
+ it('normalizes optional schema columns so every queued row has stable keys', async () => {
7
+ const tablesToUpsert = {};
8
+ await upsertPhone.transform({
9
+ batch: [
10
+ {
11
+ person_id: 1,
12
+ phone: '+12025550143',
13
+ input_id: 'input-a',
14
+ sms_status: 'Subscribed',
15
+ phone_type: 'Cell',
16
+ preference_order: 2,
17
+ phone_hash_v1: 'hash-a',
18
+ remote_phone_id: 'remote-1',
19
+ sms_deliverability_score: 7,
20
+ call_status: 'Subscribed'
21
+ },
22
+ {
23
+ person_id: 2,
24
+ phone: '+12025550144',
25
+ input_id: 'input-b',
26
+ sms_status: 'Subscribed',
27
+ phone_type: 'Cell',
28
+ preference_order: 2
29
+ // intentionally omit phone_hash_v1 / remote_phone_id / sms_deliverability_score / call_status
30
+ }
31
+ ],
32
+ databasePhones: [],
33
+ tablesToUpsert
34
+ });
35
+
36
+ assert.equal(tablesToUpsert.person_phone.length, 2);
37
+ const rowMissing = tablesToUpsert.person_phone.find((r) => r.person_id === 2);
38
+ assert.ok(rowMissing, 'expected queued row for person_id 2');
39
+
40
+ assert.ok('phone_hash_v1' in rowMissing, 'phone_hash_v1 key must exist');
41
+ assert.ok('remote_phone_id' in rowMissing, 'remote_phone_id key must exist');
42
+ assert.ok('sms_deliverability_score' in rowMissing, 'sms_deliverability_score key must exist');
43
+ assert.ok('call_status' in rowMissing, 'call_status key must exist');
44
+
45
+ assert.equal(rowMissing.phone_hash_v1, '', 'missing phone_hash_v1 normalizes to blank string');
46
+ assert.equal(rowMissing.remote_phone_id, null, 'missing remote_phone_id normalizes to null');
47
+ assert.equal(rowMissing.sms_deliverability_score, 100, 'missing sms_deliverability_score normalizes to 100');
48
+ assert.equal(rowMissing.call_status, 'Not Subscribed', 'missing call_status normalizes to Not Subscribed');
49
+ });
50
+ });
@@ -0,0 +1,58 @@
1
+ import schema from './schema.js';
2
+ import metrics from './metrics.js';
3
+ import extractRemotePersonIds from './transforms/inbound/extract_identifiers.js';
4
+ import upsertPersonRemote from './transforms/inbound/upsert_tables.js';
5
+ import appendRemotePersonId from './transforms/outbound/appendRemotePersonId.js';
6
+ const metadata = {
7
+ name: '@engine9/schemas/person_remote',
8
+ dependencies: {
9
+ '@engine9/schemas/person': '>=1.9.0'
10
+ },
11
+ // Inbound people pipeline slots -> transform export keys (woven by core when installed)
12
+ inbound: {
13
+ id: ['extractRemotePersonIds'],
14
+ upsert: ['upsertPersonRemote']
15
+ }
16
+ };
17
+ export const search = {
18
+ all: {
19
+ title: 'Remote people',
20
+ name: 'All remote people',
21
+ form: {
22
+ title: 'Remote people',
23
+ type: 'object',
24
+ properties: {
25
+ pluginId: {
26
+ title: 'Plugin ID',
27
+ type: 'string'
28
+ }
29
+ },
30
+ required: []
31
+ },
32
+ optionsToEQL: (options) => ({
33
+ table: 'person_remote',
34
+ columns: ['person_id'],
35
+ joins: [
36
+ {
37
+ table: 'input',
38
+ join_eql: `person_remote.source_input_id=input.id AND input.plugin_id='${options.pluginId}'`
39
+ }
40
+ ]
41
+ })
42
+ }
43
+ };
44
+ export const transforms = {
45
+ extractRemotePersonIds,
46
+ upsertPersonRemote,
47
+ appendRemotePersonId
48
+ };
49
+ export { metadata };
50
+ export { schema };
51
+ export { metrics };
52
+ export default {
53
+ metadata,
54
+ schema,
55
+ metrics,
56
+ search,
57
+ transforms
58
+ };
@@ -0,0 +1,17 @@
1
+ export const people = ({ plugin: { tablePrefix } }) => {
2
+ return {
3
+ label: 'Remote People',
4
+ description: '',
5
+ eql: {
6
+ table: tablePrefix + 'person',
7
+ columns: [
8
+ { eql: 'min(date_created)', name: 'first_date' },
9
+ { eql: 'max(date_created)', name: 'last_date' },
10
+ { eql: 'count', name: 'records' }
11
+ ]
12
+ }
13
+ };
14
+ };
15
+ export default {
16
+ people
17
+ };
@@ -0,0 +1,21 @@
1
+ export const tables = [
2
+ {
3
+ name: 'person_remote',
4
+ columns: {
5
+ id: 'id',
6
+ person_id: 'person_id',
7
+ remote_person_id: 'string',
8
+ source_input_id: 'foreign_uuid',
9
+ created_at: 'created_at',
10
+ modified_at: 'modified_at'
11
+ },
12
+ indexes: [
13
+ { columns: 'person_id' },
14
+ { columns: 'remote_person_id' },
15
+ { columns: ['source_input_id', 'remote_person_id', 'person_id'], unique: true }
16
+ ]
17
+ }
18
+ ];
19
+ export default {
20
+ tables
21
+ };
@@ -0,0 +1,34 @@
1
+ import { uuidIsValid } from '@engine9/input-tools';
2
+ export const type = 'id';
3
+ export const description = 'Extract the remote_person_id for use in lookups';
4
+ export async function transform({ batch, pluginId }) {
5
+ const ids = [];
6
+ batch.forEach((o) => {
7
+ o.identifiers = o.identifiers || [];
8
+ if (o.remote_person_id) {
9
+ const parts = String(o.remote_person_id).split('.');
10
+ let value = null;
11
+ if (parts.length > 1 && uuidIsValid(parts[0])) {
12
+ // There may already be a pluginId prefixed with this
13
+ value = String(o.remote_person_id).toLowerCase();
14
+ } else {
15
+ // Otherwise prefix it with the current plugin
16
+ if (!pluginId) throw new Error('A remote_person_id requires a pluginId as well, not found in options');
17
+ if (!uuidIsValid(pluginId))
18
+ throw new Error("A pluginId was specified, but it's not a UUID. Perhaps you meant remote_plugin_id?");
19
+ value = `${pluginId}.${o.remote_person_id}`.toLowerCase(); // Must be case insensitive, because the unique indexes are as well
20
+ }
21
+ o.identifiers.push({
22
+ path: 'person_remote',
23
+ type: 'remote_person_id',
24
+ value
25
+ });
26
+ }
27
+ });
28
+ return ids;
29
+ }
30
+ export default {
31
+ type,
32
+ description,
33
+ transform
34
+ };
@@ -0,0 +1,92 @@
1
+ import { mergeIntoQueue } from '@engine9/input-tools';
2
+
3
+ export const bindings = ({ pluginId } = {}) => {
4
+ if (!pluginId) {
5
+ throw new Error('pluginId is required for person_remote upsert lookup');
6
+ }
7
+ // Raw eql condition — EQUALS+string ref gets an illegal AS alias in WHERE
8
+ const safePluginId = String(pluginId).replace(/'/g, "''");
9
+ return {
10
+ databaseRemotes: {
11
+ path: 'sql.query',
12
+ options: {
13
+ table: 'person_remote',
14
+ columns: ['*'],
15
+ lookup: ['remote_person_id'],
16
+ joins: [
17
+ {
18
+ table: 'input',
19
+ join_eql: 'person_remote.source_input_id=input.id'
20
+ }
21
+ ],
22
+ conditions: [{ eql: `input.plugin_id='${safePluginId}'` }]
23
+ }
24
+ },
25
+ tablesToUpsert: { path: 'sql.tables.upsert' }
26
+ };
27
+ };
28
+ export const type = 'upsert';
29
+
30
+ function mergePersonRemote(existing, incoming) {
31
+ return {
32
+ ...existing,
33
+ ...incoming,
34
+ id: existing.id ?? incoming.id ?? null,
35
+ person_id: existing.person_id ?? incoming.person_id,
36
+ remote_person_id: existing.remote_person_id || incoming.remote_person_id,
37
+ source_input_id: existing.source_input_id ?? incoming.source_input_id
38
+ };
39
+ }
40
+
41
+ function queuePersonRemote(tablesToUpsert, row) {
42
+ tablesToUpsert.person_remote = tablesToUpsert.person_remote || [];
43
+ mergeIntoQueue(tablesToUpsert.person_remote, row, {
44
+ keyFields: ['source_input_id', 'remote_person_id', 'person_id'],
45
+ merge: mergePersonRemote,
46
+ label: 'person_remote'
47
+ });
48
+ }
49
+
50
+ export async function transform({ batch, databaseRemotes, tablesToUpsert }) {
51
+ batch.forEach((o) => {
52
+ if (!o.remote_person_id || !o.person_id) return;
53
+ const remotePersonId = String(o.remote_person_id).trim();
54
+ if (!remotePersonId) return;
55
+ // databaseRemotes is already scoped to this plugin_id via the input join
56
+ const matchingRemotes = databaseRemotes.filter((d) => d.remote_person_id === remotePersonId);
57
+ const personRemotes = matchingRemotes.filter((d) => d.person_id === o.person_id);
58
+ const existing =
59
+ personRemotes.find((d) => d.source_input_id === o.input_id) || personRemotes[0] || null;
60
+ const { id, ...rest } = o;
61
+ if (id) {
62
+ // this is undoubtedly NOT the ID of the person_remote record
63
+ }
64
+ if (existing) {
65
+ if (!existing.source_input_id) {
66
+ throw new Error(
67
+ 'Invalid source_input_id for existing person_remote record:' + JSON.stringify(existing)
68
+ );
69
+ }
70
+ queuePersonRemote(tablesToUpsert, {
71
+ ...rest,
72
+ id: existing.id,
73
+ person_id: existing.person_id,
74
+ remote_person_id: existing.remote_person_id,
75
+ source_input_id: existing.source_input_id
76
+ });
77
+ } else {
78
+ queuePersonRemote(tablesToUpsert, {
79
+ ...rest,
80
+ id: null,
81
+ person_id: o.person_id,
82
+ remote_person_id: remotePersonId,
83
+ source_input_id: o.input_id
84
+ });
85
+ }
86
+ });
87
+ }
88
+ export default {
89
+ bindings,
90
+ type,
91
+ transform
92
+ };
@@ -0,0 +1,59 @@
1
+ export const description =
2
+ 'Set a remote person id field on each row (default remote_person_id). Use options.pluginId or options.pluginIds (first match wins in list order) and optional outputField.';
3
+
4
+ export const bindings = {
5
+ remoteIds: {
6
+ path: 'sql.query',
7
+ options: {
8
+ table: 'person_remote',
9
+ columns: [
10
+ 'person_id',
11
+ 'remote_person_id',
12
+ { eql: 'input.id', name: 'input_id' },
13
+ { eql: 'input.plugin_id', name: 'plugin_id' }
14
+ ],
15
+ lookup: ['person_id'],
16
+ joins: [
17
+ {
18
+ table: 'input',
19
+ join_eql: 'person_remote.source_input_id=input.id'
20
+ }
21
+ ]
22
+ }
23
+ }
24
+ };
25
+
26
+ export const transform = (opts) => {
27
+ const { batch, remoteIds, options = {} } = opts;
28
+ const { pluginId, pluginIds, outputField = 'remote_person_id' } = options;
29
+ const orderedPluginIds = pluginIds && pluginIds.length > 0 ? pluginIds : pluginId ? [pluginId] : [];
30
+ if (orderedPluginIds.length === 0) {
31
+ throw new Error('appendRemotePersonId requires options.pluginId or options.pluginIds');
32
+ }
33
+ const byPerson = {};
34
+ for (const b of remoteIds) {
35
+ if (!orderedPluginIds.includes(b.plugin_id)) continue;
36
+ const v = b.remote_person_id;
37
+ if (!byPerson[b.person_id]) byPerson[b.person_id] = {};
38
+ if (!byPerson[b.person_id][b.plugin_id]) byPerson[b.person_id][b.plugin_id] = v;
39
+ }
40
+ batch.forEach((data) => {
41
+ let val = null;
42
+ const per = byPerson[data.person_id];
43
+ if (per) {
44
+ for (const pid of orderedPluginIds) {
45
+ if (per[pid]) {
46
+ val = per[pid];
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ data[outputField] = val || null;
52
+ });
53
+ };
54
+
55
+ export default {
56
+ description,
57
+ bindings,
58
+ transform
59
+ };
@@ -0,0 +1,13 @@
1
+ import schema from './schema.js';
2
+ import { settings } from './settings.js';
3
+ const metadata = {
4
+ name: '@engine9/schemas/plugin'
5
+ };
6
+ export { metadata };
7
+ export { schema };
8
+ export { settings };
9
+ export default {
10
+ metadata,
11
+ schema,
12
+ settings
13
+ };