@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.
- package/.eslintignore +1 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +10 -0
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/channels/email/README.md +83 -0
- package/channels/email/index.js +22 -0
- package/channels/email/search.js +86 -0
- package/channels/email/segments.js +81 -0
- package/eslint.config.js +21 -0
- package/event/README.md +79 -0
- package/event/index.js +17 -0
- package/event/schema.js +111 -0
- package/event/search.js +51 -0
- package/message/index.js +8 -0
- package/message/schema.js +130 -0
- package/message/ui.console.json5 +233 -0
- package/package.json +26 -0
- package/person/index.js +100 -0
- package/person/metrics.js +17 -0
- package/person/schema.js +47 -0
- package/person/transforms/inbound/normalize_field_names.js +16 -0
- package/person/transforms/inbound/upsert_tables.js +21 -0
- package/person/transforms/outbound/appendName.js +32 -0
- package/person/transforms/simpleMap.js +34 -0
- package/person/ui.console.json5 +165 -0
- package/person/upsert.test.js +72 -0
- package/person_address/index.js +30 -0
- package/person_address/normalize.js +232 -0
- package/person_address/normalize.test.js +69 -0
- package/person_address/schema.js +49 -0
- package/person_address/transforms/inbound/normalize.js +23 -0
- package/person_address/transforms/inbound/upsert_tables.js +51 -0
- package/person_address/transforms/outbound/appendAddress.js +87 -0
- package/person_address/ui.console.json5 +31 -0
- package/person_custom/index.js +24 -0
- package/person_custom/transforms/appendRemoteId.js +31 -0
- package/person_custom/transforms/inbound/upsert_tables.js +31 -0
- package/person_email/README.md +82 -0
- package/person_email/index.js +35 -0
- package/person_email/schema.js +49 -0
- package/person_email/search.js +61 -0
- package/person_email/segments.js +16 -0
- package/person_email/transforms/inbound/extract_identifiers.js +33 -0
- package/person_email/transforms/inbound/upsert_tables.js +142 -0
- package/person_email/transforms/outbound/appendEmail.js +32 -0
- package/person_email/transforms/outbound/appendEmailHash.js +20 -0
- package/person_email/transforms/outbound/tables.js +14 -0
- package/person_email/ui.console.json5 +58 -0
- package/person_email/upsert.test.js +126 -0
- package/person_hash/README.md +70 -0
- package/person_hash/hash.js +103 -0
- package/person_hash/hash.test.js +165 -0
- package/person_hash/index.js +37 -0
- package/person_hash/schema.js +54 -0
- package/person_hash/search.js +86 -0
- package/person_hash/transforms/inbound/extract_identifiers.js +70 -0
- package/person_hash/transforms/inbound/upsert_tables.js +105 -0
- package/person_hash/transforms/outbound/appendEmailHash.js +42 -0
- package/person_hash/transforms/outbound/appendPhoneHash.js +42 -0
- package/person_hash/ui.console.json5 +80 -0
- package/person_phone/README.md +79 -0
- package/person_phone/index.js +33 -0
- package/person_phone/schema.js +58 -0
- package/person_phone/search.js +79 -0
- package/person_phone/segments.js +26 -0
- package/person_phone/transforms/inbound/extract_identifiers.js +59 -0
- package/person_phone/transforms/inbound/upsert_tables.js +119 -0
- package/person_phone/transforms/outbound/appendPhoneHash.js +37 -0
- package/person_phone/ui.console.json5 +31 -0
- package/person_phone/upsert.test.js +50 -0
- package/person_remote/index.js +58 -0
- package/person_remote/metrics.js +17 -0
- package/person_remote/schema.js +21 -0
- package/person_remote/transforms/inbound/extract_identifiers.js +34 -0
- package/person_remote/transforms/inbound/upsert_tables.js +92 -0
- package/person_remote/transforms/outbound/appendRemotePersonId.js +59 -0
- package/plugin/index.js +13 -0
- package/plugin/schema.js +70 -0
- package/plugin/settings.js +19 -0
- package/plugin/ui.console.json5 +79 -0
- package/report/index.js +7 -0
- package/report/ui.console.json5 +21 -0
- package/segment/index.js +21 -0
- package/segment/schema.js +81 -0
- package/segment/search.js +64 -0
- package/segment/transforms/inbound/upsert.js +44 -0
- package/segment/ui.console.json5 +142 -0
- package/segment_stats/index.js +16 -0
- package/segment_stats/schema.js +30 -0
- package/source_code/index.js +16 -0
- package/source_code/metrics.js +28 -0
- package/source_code/schema.js +29 -0
- package/source_code/ui.console.json5 +31 -0
- package/stacks/limited-pii/index.js +8 -0
- package/stacks/limited-pii/stack.json +22 -0
- package/stacks/stacks.test.js +27 -0
- package/stacks/standard/index.js +7 -0
- package/stacks/standard/stack.json +20 -0
- package/tag/index.js +13 -0
- package/tag/schema.js +23 -0
- package/task/index.js +13 -0
- package/task/schema.js +54 -0
- package/task/ui.console.json5 +60 -0
- package/timeline/index.js +18 -0
- package/timeline/schema.js +18 -0
- package/timeline/transforms/inbound/upsert_tables.js +22 -0
- package/timeline/ui.console.json5 +31 -0
- package/transaction/contact_details/index.js +11 -0
- package/transaction/contact_details/schema.js +22 -0
- package/transaction/core/README.md +79 -0
- package/transaction/core/attribute.test.js +231 -0
- package/transaction/core/index.js +31 -0
- package/transaction/core/metrics.js +20 -0
- package/transaction/core/schema.js +45 -0
- package/transaction/core/search.js +87 -0
- package/transaction/core/segments.js +14 -0
- package/transaction/core/transforms/appendTransactionSummary.js +101 -0
- package/transaction/core/transforms/attribute.js +327 -0
- package/transaction/core/transforms/inbound/upsert_tables.js +57 -0
- package/utilities/limited-pii/index.js +14 -0
- 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
|
+
};
|