@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,165 @@
1
+ {
2
+ menu: {
3
+ people: {
4
+ title: 'People',
5
+ icon: 'people',
6
+ type: 'collapse',
7
+ children: {
8
+ 'person-list': {
9
+ title: 'Everybody',
10
+ icon: 'person',
11
+ url: '/person'
12
+ }
13
+ }
14
+ }
15
+ },
16
+ routes: {
17
+ person: {
18
+ layout: 'full_width',
19
+ components: {
20
+ header: [
21
+ 'Everybody',
22
+ {
23
+ component: 'Button',
24
+ properties: {
25
+ icon: 'plus',
26
+ content: 'Add Person',
27
+ onClick: {
28
+ action: 'navigate',
29
+ url: '/person/create'
30
+ }
31
+ }
32
+ }
33
+ ],
34
+ main: [
35
+ {
36
+ component: 'RecordList',
37
+ properties: {
38
+ table: 'person',
39
+ includes: {
40
+ emails: {
41
+ table: 'person_email'
42
+ }
43
+ },
44
+ columns: [
45
+ {
46
+ title: 'Id',
47
+ dataIndex: 'id',
48
+ sorter: true,
49
+ width: '20%'
50
+ },
51
+ {
52
+ title: 'Name',
53
+ dataIndex: 'name',
54
+ sorter: true,
55
+ template: '{{given_name}} {{family_name}} {{emails.0.email}}',
56
+ width: '40%'
57
+ },
58
+ {
59
+ title: 'Email',
60
+ template: '{{emails.0.email}}',
61
+ width: '40%'
62
+ }
63
+ ],
64
+ onRow: {
65
+ onClick: {
66
+ action: 'navigate',
67
+ url: '/person/{{record.id}}'
68
+ }
69
+ }
70
+ }
71
+ }
72
+ ]
73
+ }
74
+ },
75
+ 'person/create': {
76
+ layout: 'full_width',
77
+ components: {
78
+ main: [
79
+ {
80
+ path: 'edit',
81
+ component: 'RecordForm',
82
+ properties: {
83
+ table: 'person',
84
+ form: {
85
+ title: 'Todo',
86
+ type: 'object',
87
+ required: [],
88
+ properties: {
89
+ given_name: { type: 'string', title: 'First Name', default: '' },
90
+ family_name: { type: 'string', title: 'Last Name', default: '' }
91
+ }
92
+ },
93
+ uiSchema: {
94
+ family_name: { 'ui:widget': 'textarea' }
95
+ },
96
+ onSave: {
97
+ action: 'navigate',
98
+ url: '/person/{{record.id}}'
99
+ }
100
+ }
101
+ }
102
+ ]
103
+ }
104
+ },
105
+ 'person/:id/*': {
106
+ layout: 'sidebar',
107
+ components: {
108
+ sidebar: {
109
+ title: 'Person Details',
110
+ items: [
111
+ {
112
+ component: 'RecordDisplay',
113
+ properties: {
114
+ table: 'person',
115
+ id: '{{parameters.id}}',
116
+ includes: {
117
+ emails: {
118
+ table: 'person_email',
119
+ orderBy: 'preference_order'
120
+ }
121
+ },
122
+ template: '{{given_name}} {{family_name}} {{#if emails.0.email}}<{{emails.0.email}}>{{/if}}'
123
+ }
124
+ },
125
+ {
126
+ component: 'Link',
127
+ properties: {
128
+ label: 'Edit',
129
+ to: 'edit'
130
+ }
131
+ }
132
+ ]
133
+ },
134
+ main: [
135
+ {
136
+ // 'path':'overview', //no path is the default
137
+ component: 'RecordDisplay',
138
+ properties: {
139
+ table: 'person',
140
+ id: '{{parameters.id}}',
141
+ template: '{{given_name}} {{family_name}}'
142
+ }
143
+ },
144
+ {
145
+ path: 'edit',
146
+ component: 'RecordForm',
147
+ properties: {
148
+ table: 'person',
149
+ id: '{{parameters.id}}',
150
+ form: {
151
+ title: 'Todo',
152
+ type: 'object',
153
+ required: [],
154
+ properties: {
155
+ given_name: { type: 'string', title: 'First Name', default: '' },
156
+ family_name: { type: 'string', title: 'Last Name', default: '' }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ ]
162
+ }
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import upsertPerson from './transforms/inbound/upsert_tables.js';
4
+
5
+ describe('person upsert_tables', () => {
6
+ it('queues a name column only when that key is present', async () => {
7
+ const tablesToUpsert = {};
8
+ await upsertPerson.transform({
9
+ batch: [{ person_id: 1, email: 'ada@example.com', given_name: 'Ada' }],
10
+ tablesToUpsert
11
+ });
12
+ assert.deepEqual(tablesToUpsert.person, [{ id: 1, given_name: 'Ada' }]);
13
+ assert.equal('family_name' in tablesToUpsert.person[0], false);
14
+ });
15
+
16
+ it('queues family_name alone when given_name is omitted', async () => {
17
+ const tablesToUpsert = {};
18
+ await upsertPerson.transform({
19
+ batch: [{ person_id: 2, email: 'lovelace@example.com', family_name: 'Lovelace' }],
20
+ tablesToUpsert
21
+ });
22
+ assert.deepEqual(tablesToUpsert.person, [{ id: 2, family_name: 'Lovelace' }]);
23
+ assert.equal('given_name' in tablesToUpsert.person[0], false);
24
+ });
25
+
26
+ it('queues both names when both keys are present', async () => {
27
+ const tablesToUpsert = {};
28
+ await upsertPerson.transform({
29
+ batch: [{ person_id: 3, given_name: 'Ada', family_name: 'Lovelace' }],
30
+ tablesToUpsert
31
+ });
32
+ assert.deepEqual(tablesToUpsert.person, [{ id: 3, given_name: 'Ada', family_name: 'Lovelace' }]);
33
+ });
34
+
35
+ it('keeps an explicit null so that column clears', async () => {
36
+ const tablesToUpsert = {};
37
+ await upsertPerson.transform({
38
+ batch: [{ person_id: 4, given_name: 'Ada', family_name: null }],
39
+ tablesToUpsert
40
+ });
41
+ assert.deepEqual(tablesToUpsert.person, [{ id: 4, given_name: 'Ada', family_name: null }]);
42
+ });
43
+
44
+ it('clears a name when that key is present and null and the other name is omitted', async () => {
45
+ const tablesToUpsert = {};
46
+ await upsertPerson.transform({
47
+ batch: [{ person_id: 5, family_name: null }],
48
+ tablesToUpsert
49
+ });
50
+ assert.deepEqual(tablesToUpsert.person, [{ id: 5, family_name: null }]);
51
+ });
52
+
53
+ it('omits the person upsert when neither name key is present', async () => {
54
+ const tablesToUpsert = {};
55
+ await upsertPerson.transform({
56
+ batch: [{ person_id: 6, email: 'ada@example.com' }, { email: 'no-id@example.com' }],
57
+ tablesToUpsert
58
+ });
59
+ assert.equal('person' in tablesToUpsert, false);
60
+ });
61
+
62
+ it('throws when a name is present and person_id is missing', async () => {
63
+ await assert.rejects(
64
+ () =>
65
+ upsertPerson.transform({
66
+ batch: [{ given_name: 'Ada' }],
67
+ tablesToUpsert: {}
68
+ }),
69
+ /no person_id/
70
+ );
71
+ });
72
+ });
@@ -0,0 +1,30 @@
1
+ import upsertPersonAddress from './transforms/inbound/upsert_tables.js';
2
+ import normalize from './transforms/inbound/normalize.js';
3
+ import appendAddress from './transforms/outbound/appendAddress.js';
4
+ import schema from './schema.js';
5
+
6
+ const metadata = {
7
+ name: '@engine9/schemas/person_address',
8
+ dependencies: {
9
+ '@engine9/schemas/person': '>=1.9.0'
10
+ },
11
+ // Inbound people pipeline slots -> transform export keys (woven by core when installed).
12
+ // `normalize` stays a standalone export; upsert already normalizes inline.
13
+ inbound: {
14
+ upsert: ['upsertPersonAddress']
15
+ }
16
+ };
17
+
18
+ export const transforms = {
19
+ normalize,
20
+ upsertPersonAddress,
21
+ appendAddress
22
+ };
23
+
24
+ export { metadata };
25
+ export { schema };
26
+ export default {
27
+ metadata,
28
+ schema,
29
+ transforms
30
+ };
@@ -0,0 +1,232 @@
1
+ import { addressNormalize } from '@zerodep/address-normalize';
2
+ import countries from 'i18n-iso-countries';
3
+ import enLocale from 'i18n-iso-countries/langs/en.json' with { type: 'json' };
4
+
5
+ countries.registerLocale(enLocale);
6
+
7
+ /** US state / DC / territory names and common aliases → ISO-3166-2 region codes */
8
+ const US_REGION_ALIASES = {
9
+ alabama: 'AL',
10
+ alaska: 'AK',
11
+ arizona: 'AZ',
12
+ arkansas: 'AR',
13
+ california: 'CA',
14
+ colorado: 'CO',
15
+ connecticut: 'CT',
16
+ delaware: 'DE',
17
+ 'district of columbia': 'DC',
18
+ florida: 'FL',
19
+ georgia: 'GA',
20
+ hawaii: 'HI',
21
+ idaho: 'ID',
22
+ illinois: 'IL',
23
+ indiana: 'IN',
24
+ iowa: 'IA',
25
+ kansas: 'KS',
26
+ kentucky: 'KY',
27
+ louisiana: 'LA',
28
+ maine: 'ME',
29
+ maryland: 'MD',
30
+ massachusetts: 'MA',
31
+ michigan: 'MI',
32
+ minnesota: 'MN',
33
+ mississippi: 'MS',
34
+ missouri: 'MO',
35
+ montana: 'MT',
36
+ nebraska: 'NE',
37
+ nevada: 'NV',
38
+ 'new hampshire': 'NH',
39
+ 'new jersey': 'NJ',
40
+ 'new mexico': 'NM',
41
+ 'new york': 'NY',
42
+ 'north carolina': 'NC',
43
+ 'north dakota': 'ND',
44
+ ohio: 'OH',
45
+ oklahoma: 'OK',
46
+ oregon: 'OR',
47
+ pennsylvania: 'PA',
48
+ 'rhode island': 'RI',
49
+ 'south carolina': 'SC',
50
+ 'south dakota': 'SD',
51
+ tennessee: 'TN',
52
+ texas: 'TX',
53
+ utah: 'UT',
54
+ vermont: 'VT',
55
+ virginia: 'VA',
56
+ washington: 'WA',
57
+ 'west virginia': 'WV',
58
+ wisconsin: 'WI',
59
+ wyoming: 'WY',
60
+ 'puerto rico': 'PR',
61
+ guam: 'GU',
62
+ 'american samoa': 'AS',
63
+ 'us virgin islands': 'VI',
64
+ 'u.s. virgin islands': 'VI',
65
+ 'virgin islands': 'VI',
66
+ 'northern mariana islands': 'MP'
67
+ };
68
+
69
+ /** Canadian province / territory names → codes */
70
+ const CA_REGION_ALIASES = {
71
+ alberta: 'AB',
72
+ 'british columbia': 'BC',
73
+ manitoba: 'MB',
74
+ 'new brunswick': 'NB',
75
+ 'newfoundland and labrador': 'NL',
76
+ newfoundland: 'NL',
77
+ 'northwest territories': 'NT',
78
+ 'nova scotia': 'NS',
79
+ nunavut: 'NU',
80
+ ontario: 'ON',
81
+ 'prince edward island': 'PE',
82
+ quebec: 'QC',
83
+ québec: 'QC',
84
+ saskatchewan: 'SK',
85
+ yukon: 'YT',
86
+ 'yukon territory': 'YT'
87
+ };
88
+
89
+ const VALID_US_REGIONS = new Set(Object.values(US_REGION_ALIASES));
90
+ const VALID_CA_REGIONS = new Set(Object.values(CA_REGION_ALIASES));
91
+
92
+ function emptyToNull(value) {
93
+ if (value == null) return null;
94
+ const s = String(value).trim();
95
+ return s === '' ? null : s;
96
+ }
97
+
98
+ /**
99
+ * Normalize a street / city string for storage and dedupe.
100
+ * Does not validate deliverability — only standardizes abbreviations and punctuation.
101
+ */
102
+ export function normalizeStreetLine(value) {
103
+ const raw = emptyToNull(value);
104
+ if (!raw) return null;
105
+ try {
106
+ const normalized = addressNormalize(raw);
107
+ return emptyToNull(normalized);
108
+ } catch {
109
+ // e.g. ZeroDep 200-char limit — fall back to a light cleanup
110
+ return emptyToNull(
111
+ raw
112
+ .normalize('NFKD')
113
+ .replace(/[\u0300-\u036f]/g, '')
114
+ .toUpperCase()
115
+ .replace(/[^A-Z0-9#/\-.\s]/g, ' ')
116
+ .replace(/\s+/g, ' ')
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Map country names / alpha-3 / numeric codes to ISO 3166-1 alpha-2.
123
+ * Unknown values are uppercased (not rejected) so inbound data is not dropped.
124
+ */
125
+ export function normalizeCountry(value, { defaultCountry = 'US' } = {}) {
126
+ const raw = emptyToNull(value);
127
+ if (!raw) return defaultCountry;
128
+
129
+ const trimmed = raw.trim();
130
+ const upper = trimmed.toUpperCase();
131
+
132
+ if (countries.isValid(upper) && upper.length === 2) return upper;
133
+
134
+ const fromAlpha3 = countries.alpha3ToAlpha2(upper);
135
+ if (fromAlpha3) return fromAlpha3;
136
+
137
+ if (/^\d{1,3}$/.test(upper)) {
138
+ const fromNumeric = countries.numericToAlpha2(upper.padStart(3, '0'));
139
+ if (fromNumeric) return fromNumeric;
140
+ }
141
+
142
+ const fromName = countries.getSimpleAlpha2Code(trimmed, 'en') || countries.getAlpha2Code(trimmed, 'en');
143
+ if (fromName) return fromName;
144
+
145
+ // Common non-ISO alias
146
+ if (upper === 'UK') return 'GB';
147
+
148
+ // Keep short codes; otherwise leave a cleaned uppercase token (do not invent a code)
149
+ if (upper.length <= 3) return upper;
150
+ return upper;
151
+ }
152
+
153
+ export function normalizeRegion(value, country) {
154
+ const raw = emptyToNull(value);
155
+ if (!raw) return null;
156
+ const upper = raw.toUpperCase().replace(/\./g, '').trim();
157
+ const lower = upper.toLowerCase();
158
+
159
+ if (country === 'US') {
160
+ if (VALID_US_REGIONS.has(upper)) return upper;
161
+ if (US_REGION_ALIASES[lower]) return US_REGION_ALIASES[lower];
162
+ }
163
+ if (country === 'CA') {
164
+ if (VALID_CA_REGIONS.has(upper)) return upper;
165
+ if (CA_REGION_ALIASES[lower]) return CA_REGION_ALIASES[lower];
166
+ }
167
+
168
+ // Already a short code (e.g. NY, ON) or unknown — keep compact uppercase
169
+ if (upper.length <= 3) return upper;
170
+ return upper;
171
+ }
172
+
173
+ /**
174
+ * Light postal cleanup: US ZIP+4 → ZIP5, CA → A1A 1A1, otherwise trim/uppercase.
175
+ */
176
+ export function normalizePostalCode(value, country) {
177
+ const raw = emptyToNull(value);
178
+ if (!raw) return null;
179
+ const upper = raw.toUpperCase().trim();
180
+
181
+ if (country === 'US') {
182
+ const digits = upper.replace(/\D/g, '');
183
+ if (digits.length >= 5) return digits.slice(0, 5);
184
+ return emptyToNull(upper.replace(/[^A-Z0-9]/g, ''));
185
+ }
186
+
187
+ if (country === 'CA') {
188
+ const alnum = upper.replace(/[^A-Z0-9]/g, '');
189
+ if (alnum.length === 6) return `${alnum.slice(0, 3)} ${alnum.slice(3)}`;
190
+ return emptyToNull(alnum);
191
+ }
192
+
193
+ return emptyToNull(upper.replace(/\s+/g, ' '));
194
+ }
195
+
196
+ /**
197
+ * Normalize address fields used by person_address upsert / hashing.
198
+ * Mutates and returns a plain object of normalized fields (does not mutate input).
199
+ */
200
+ export function normalizeAddressFields(addr = {}, { defaultCountry = 'US' } = {}) {
201
+ const country = normalizeCountry(addr.country, { defaultCountry });
202
+ return {
203
+ street_1: normalizeStreetLine(addr.street_1),
204
+ street_2: normalizeStreetLine(addr.street_2),
205
+ street_3: normalizeStreetLine(addr.street_3),
206
+ city: normalizeStreetLine(addr.city),
207
+ region: normalizeRegion(addr.region, country),
208
+ postal_code: normalizePostalCode(addr.postal_code, country),
209
+ country
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Dedupe hash for addresses: alnum of street_1 + street_2 + postal_code after normalize.
215
+ */
216
+ export function hashAddress(addr, { defaultCountry = 'US' } = {}) {
217
+ const n = normalizeAddressFields(addr, { defaultCountry });
218
+ return [n.street_1, n.street_2, n.postal_code]
219
+ .filter(Boolean)
220
+ .join('')
221
+ .replace(/[^a-z0-9]+/gi, '')
222
+ .toLowerCase();
223
+ }
224
+
225
+ export default {
226
+ normalizeStreetLine,
227
+ normalizeCountry,
228
+ normalizeRegion,
229
+ normalizePostalCode,
230
+ normalizeAddressFields,
231
+ hashAddress
232
+ };
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import {
4
+ hashAddress,
5
+ normalizeAddressFields,
6
+ normalizeCountry,
7
+ normalizePostalCode,
8
+ normalizeRegion,
9
+ normalizeStreetLine
10
+ } from './normalize.js';
11
+
12
+ describe('person_address normalize', () => {
13
+ it('normalizes street abbreviations without validating deliverability', () => {
14
+ assert.equal(normalizeStreetLine('1234 Main Street south-west, apt 14-a'), '1234 MAIN ST SW APT 14-A');
15
+ assert.equal(normalizeStreetLine('p.o.b 1234'), 'PO BOX 1234');
16
+ });
17
+
18
+ it('normalizes country names and codes to ISO alpha-2', () => {
19
+ assert.equal(normalizeCountry('USA'), 'US');
20
+ assert.equal(normalizeCountry('United States'), 'US');
21
+ assert.equal(normalizeCountry('840'), 'US');
22
+ assert.equal(normalizeCountry('UK'), 'GB');
23
+ assert.equal(normalizeCountry('Canada'), 'CA');
24
+ assert.equal(normalizeCountry(null), 'US');
25
+ });
26
+
27
+ it('normalizes US/CA regions', () => {
28
+ assert.equal(normalizeRegion('California', 'US'), 'CA');
29
+ assert.equal(normalizeRegion('ny', 'US'), 'NY');
30
+ assert.equal(normalizeRegion('Ontario', 'CA'), 'ON');
31
+ });
32
+
33
+ it('normalizes postal codes', () => {
34
+ assert.equal(normalizePostalCode('10001-1234', 'US'), '10001');
35
+ assert.equal(normalizePostalCode('m5v0h5', 'CA'), 'M5V 0H5');
36
+ });
37
+
38
+ it('hashes equivalent addresses the same after normalize', () => {
39
+ const a = hashAddress({
40
+ street_1: '123 Main Street',
41
+ street_2: 'Apt 2',
42
+ postal_code: '10001-9999',
43
+ country: 'USA'
44
+ });
45
+ const b = hashAddress({
46
+ street_1: '123 MAIN ST',
47
+ street_2: 'APT 2',
48
+ postal_code: '10001',
49
+ country: 'US'
50
+ });
51
+ assert.equal(a, b);
52
+ assert.ok(a.length > 0);
53
+ });
54
+
55
+ it('returns normalized field set', () => {
56
+ const n = normalizeAddressFields({
57
+ street_1: '1005 N Gravenstein Highway',
58
+ city: 'Sebastopol',
59
+ region: 'California',
60
+ postal_code: '95472-1234',
61
+ country: 'United States of America'
62
+ });
63
+ assert.equal(n.country, 'US');
64
+ assert.equal(n.region, 'CA');
65
+ assert.equal(n.postal_code, '95472');
66
+ assert.equal(n.city, 'SEBASTOPOL');
67
+ assert.match(n.street_1, /GRAVENSTEIN/);
68
+ });
69
+ });
@@ -0,0 +1,49 @@
1
+ export const tables = [
2
+ {
3
+ name: 'person_address',
4
+ columns: {
5
+ id: 'id',
6
+ person_id: 'person_id',
7
+ name: 'string',
8
+ type: {
9
+ type: 'string',
10
+ values: ['', 'Home', 'School', 'Office']
11
+ },
12
+ status: 'string',
13
+ street_1: 'string',
14
+ street_2: 'string',
15
+ street_3: 'string',
16
+ city: 'string',
17
+ region: 'string',
18
+ postal_code: 'string',
19
+ country: 'string',
20
+ subscription_status: {
21
+ type: 'string',
22
+ nullable: false,
23
+ default_value: 'Not Subscribed',
24
+ values: ['Blocked', 'Bounced', 'Not Subscribed', 'Subscribed', 'Unsubscribed']
25
+ },
26
+ deliverability_score: {
27
+ type: 'int',
28
+ description:
29
+ 'Score representating deliverability status of address, 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, lower is better, -1 means set by user',
36
+ nullable: false,
37
+ default_value: 0
38
+ },
39
+ is_best_address: 'boolean',
40
+ created_at: 'created_at',
41
+ modified_at: 'modified_at',
42
+ source_input_id: 'foreign_uuid'
43
+ },
44
+ indexes: [{ columns: 'person_id' }]
45
+ }
46
+ ];
47
+ export default {
48
+ tables
49
+ };
@@ -0,0 +1,23 @@
1
+ import { normalizeAddressFields } from '../../normalize.js';
2
+
3
+ export const description =
4
+ 'Normalize postal address fields (street abbreviations, region codes, country ISO alpha-2, postal). Does not validate deliverability.';
5
+
6
+ /**
7
+ * Mutate batch rows in place with normalized address components.
8
+ */
9
+ export async function transform({ batch, options = {} }) {
10
+ const defaultCountry = options.defaultCountry || options.default_country || 'US';
11
+ batch.forEach((row) => {
12
+ const hasAddressHint = row.street_1 || row.street_2 || row.postal_code || row.city || row.region || row.country;
13
+ if (!hasAddressHint) return;
14
+ const normalized = normalizeAddressFields(row, { defaultCountry });
15
+ Object.assign(row, normalized);
16
+ });
17
+ return batch;
18
+ }
19
+
20
+ export default {
21
+ description,
22
+ transform
23
+ };
@@ -0,0 +1,51 @@
1
+ import { hashAddress, normalizeAddressFields } from '../../normalize.js';
2
+
3
+ export const bindings = {
4
+ existingAddresses: { path: 'sql.query', options: { table: 'person_address', lookup: ['person_id'] } },
5
+ tablesToUpsert: { path: 'sql.tables.upsert' }
6
+ };
7
+
8
+ export async function transform({ batch, tablesToUpsert, existingAddresses, options = {} }) {
9
+ const defaultCountry = options.defaultCountry || options.default_country || 'US';
10
+ tablesToUpsert.person_address = tablesToUpsert.person_address || [];
11
+ batch.forEach((o) => {
12
+ const normalized = normalizeAddressFields(o, { defaultCountry });
13
+ const hash = hashAddress(normalized, { defaultCountry });
14
+ if (hash.length === 0) return;
15
+
16
+ const matchingAddress = existingAddresses.find(
17
+ (a) => a.person_id === o.person_id && hashAddress(a, { defaultCountry }) === hash
18
+ );
19
+
20
+ const record = {
21
+ id: null,
22
+ type: o.type || 'Home',
23
+ person_id: o.person_id,
24
+ street_1: normalized.street_1,
25
+ street_2: normalized.street_2,
26
+ street_3: normalized.street_3,
27
+ city: normalized.city,
28
+ region: normalized.region,
29
+ postal_code: normalized.postal_code,
30
+ country: normalized.country,
31
+ subscription_status: 'Not Subscribed',
32
+ source_input_id: o.input_id
33
+ };
34
+ if (matchingAddress) {
35
+ /* Put logic here for dealing with overrides, subscription status, etc */
36
+ if (matchingAddress.subscription_status === 'Unsubscribed') {
37
+ record.subscription_status = 'Unsubscribed';
38
+ }
39
+ record.id = matchingAddress.id;
40
+ record.source_input_id = matchingAddress.source_input_id; // keep this the same
41
+ if (matchingAddress.type) record.type = matchingAddress.type;
42
+ }
43
+ tablesToUpsert.person_address.push(record);
44
+ });
45
+ return batch;
46
+ }
47
+
48
+ export default {
49
+ bindings,
50
+ transform
51
+ };