@aglyn/plugins-outreach 1.0.0-beta.220 → 1.0.0-beta.222
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/package.json +14 -14
- package/src/lib/components/do-not-contact-domains.d.ts +6 -2
- package/src/lib/components/do-not-contact-domains.js +73 -11
- package/src/lib/components/do-not-contact-domains.js.map +1 -1
- package/src/lib/components/enrollments-table.d.ts +6 -0
- package/src/lib/components/enrollments-table.js +77 -4
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/sequence-detail.js +3 -1
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.js +7 -3
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/use-outreach-crm.d.ts +18 -15
- package/src/lib/components/use-outreach-crm.js +118 -87
- package/src/lib/components/use-outreach-crm.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +2 -2
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/transfer-resources.d.ts +33 -0
- package/src/lib/constants/transfer-resources.js +38 -0
- package/src/lib/constants/transfer-resources.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +7 -1
- package/src/lib/declarations.console-server.js +66 -1
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/engine/click-tracking.js +1 -1
- package/src/lib/engine/click-tracking.js.map +1 -1
- package/src/lib/engine/ip-range.d.ts +26 -0
- package/src/lib/engine/ip-range.js +78 -0
- package/src/lib/engine/ip-range.js.map +1 -0
- package/src/lib/engine/open-source.d.ts +24 -0
- package/src/lib/engine/open-source.js +130 -0
- package/src/lib/engine/open-source.js.map +1 -0
- package/src/lib/engine/open-tracking.d.ts +15 -2
- package/src/lib/engine/open-tracking.js +59 -16
- package/src/lib/engine/open-tracking.js.map +1 -1
- package/src/lib/engine/outreach-access.d.ts +47 -0
- package/src/lib/engine/outreach-access.js +56 -0
- package/src/lib/engine/outreach-access.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +10 -10
- package/src/lib/enrollment/enroll-people.js +30 -25
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.js +30 -15
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/enrollment/message-templates.d.ts +24 -0
- package/src/lib/enrollment/message-templates.js +35 -0
- package/src/lib/enrollment/message-templates.js.map +1 -0
- package/src/lib/model/enrollment-engagement.d.ts +16 -0
- package/src/lib/model/enrollment-engagement.js +16 -0
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +8 -1
- package/src/lib/model/enrollment-history.js +17 -5
- package/src/lib/model/enrollment-history.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +3 -0
- package/src/lib/model/enrollment-timeline.js +15 -4
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +12 -3
- package/src/lib/model/outreach.types.js +12 -1
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-report.d.ts +14 -7
- package/src/lib/model/sequence-report.js +12 -8
- package/src/lib/model/sequence-report.js.map +1 -1
- package/src/lib/plugin.js +18 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/curate-routes.js +4 -6
- package/src/lib/routes/curate-routes.js.map +1 -1
- package/src/lib/routes/enroll-routes.d.ts +0 -12
- package/src/lib/routes/enroll-routes.js +52 -92
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +4 -16
- package/src/lib/routes/platform-deps.js +5 -25
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.js +25 -13
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +7 -5
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-gate.js +6 -17
- package/src/lib/routes/route-gate.js.map +1 -1
- package/src/lib/routes/sequence-routes.d.ts +46 -0
- package/src/lib/routes/sequence-routes.js +155 -63
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.js +8 -8
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-route.js +5 -2
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/lead-records.d.ts +18 -10
- package/src/lib/runtime/lead-records.js +14 -60
- package/src/lib/runtime/lead-records.js.map +1 -1
- package/src/lib/runtime/open-events.d.ts +8 -0
- package/src/lib/runtime/open-events.js +10 -3
- package/src/lib/runtime/open-events.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.d.ts +9 -5
- package/src/lib/runtime/platform-runtime-deps.js +31 -24
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +20 -17
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +10 -5
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.d.ts +8 -0
- package/src/lib/storage/do-not-contact-store.js +13 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
- package/src/lib/testing/stand-in-record-system.d.ts +54 -0
- package/src/lib/testing/stand-in-record-system.js +243 -0
- package/src/lib/testing/stand-in-record-system.js.map +1 -0
- package/src/lib/transfer/do-not-contact-transfer.d.ts +129 -0
- package/src/lib/transfer/do-not-contact-transfer.js +801 -0
- package/src/lib/transfer/do-not-contact-transfer.js.map +1 -0
- package/src/lib/transfer/plan-gate.d.ts +25 -0
- package/src/lib/transfer/plan-gate.js +35 -0
- package/src/lib/transfer/plan-gate.js.map +1 -0
- package/src/lib/transfer/platform-transfer-deps.d.ts +24 -0
- package/src/lib/transfer/platform-transfer-deps.js +39 -0
- package/src/lib/transfer/platform-transfer-deps.js.map +1 -0
- package/src/lib/transfer/sequences-package.d.ts +48 -0
- package/src/lib/transfer/sequences-package.js +93 -0
- package/src/lib/transfer/sequences-package.js.map +1 -0
- package/src/lib/transfer/sequences-package.server.d.ts +31 -0
- package/src/lib/transfer/sequences-package.server.js +207 -0
- package/src/lib/transfer/sequences-package.server.js.map +1 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright 2026 Aglyn LLC
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
* you may not use this file except in compliance with the License.
|
|
9
|
+
* You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
19
|
+
import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
|
|
20
|
+
import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
|
|
21
|
+
import { buildTransferPlan, matchLookupKey, planTransferUndo, TRANSFER_ID_FIELD, transferIdField, withTransferResourceFindings } from "@aglyn/aglyn/data-transfer";
|
|
22
|
+
import { LIST_QUERY_ID_PATH, planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
23
|
+
import { applyListQuery } from "@aglyn/tenant-data-admin/server/list-query";
|
|
24
|
+
import { FieldPath } from "firebase-admin/firestore";
|
|
25
|
+
import { OUTREACH_DNC_MATCH_KEYS } from "../constants/transfer-resources.js";
|
|
26
|
+
import { outreachDoNotContactKey } from "../engine/do-not-contact.js";
|
|
27
|
+
import { normalizeOutreachDomain, outreachEmailDomain } from "../engine/do-not-contact-domain.js";
|
|
28
|
+
import { outreachEntitlementRefusal, outreachMembershipRefusal } from "../engine/outreach-access.js";
|
|
29
|
+
import { OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY } from "../model/do-not-contact-domain-list-query.js";
|
|
30
|
+
import { OUTREACH_DO_NOT_CONTACT_REASON_LABELS, OUTREACH_DO_NOT_CONTACT_SOURCE_LABELS } from "../model/outreach.types.js";
|
|
31
|
+
import { addOutreachDoNotContact, addOutreachDoNotContactDomain, lookupOutreachDoNotContactDomains, outreachDoNotContactCollection, outreachDoNotContactDomainCollection, readOutreachDoNotContactDomainEntry, readOutreachDoNotContactEntry, removeOutreachDoNotContactDomain, removeOutreachDoNotContactEntry } from "../storage/do-not-contact-store.js";
|
|
32
|
+
/**
|
|
33
|
+
* THE DO-NOT-CONTACT LIST AS A FILE: `outreach.do-not-contact`, an
|
|
34
|
+
* organization's addresses and domains that no sequence emails.
|
|
35
|
+
*
|
|
36
|
+
* ## One file, two collections
|
|
37
|
+
*
|
|
38
|
+
* A row is one entry, and its Entry cell says which kind: an email address
|
|
39
|
+
* goes to `orgs/{orgId}/outreachDoNotContact/{key}`, anything else that reads
|
|
40
|
+
* as a domain to `orgs/{orgId}/outreachDoNotContactDomains/{domain}`. The
|
|
41
|
+
* Aglyn ID names the kind too — `domain:<domain>` or `address:<key>` — so a
|
|
42
|
+
* re-imported export finds its entries by id.
|
|
43
|
+
*
|
|
44
|
+
* ## Only ever added to
|
|
45
|
+
*
|
|
46
|
+
* Every entry is written once and never edited (the store's `create()`), and
|
|
47
|
+
* the first reason an entry was added is the record of why it is there. An
|
|
48
|
+
* import keeps that: a row that finds an entry already on the list is
|
|
49
|
+
* skipped whatever the wizard's policy says (`plan` forces it), and only the
|
|
50
|
+
* entries an import ADDED are taken off again by its undo.
|
|
51
|
+
*
|
|
52
|
+
* ## Addresses go in and never come out
|
|
53
|
+
*
|
|
54
|
+
* An address entry stores the address's fingerprint, never the address, so
|
|
55
|
+
* the list keeps a promise not to email someone without holding who they
|
|
56
|
+
* are. An export therefore reads domains only: an address cannot be read
|
|
57
|
+
* back out of its fingerprint.
|
|
58
|
+
*
|
|
59
|
+
* ## The same door as the Compliance page
|
|
60
|
+
*
|
|
61
|
+
* Every hook asks what the route gate asks — a member of this organization
|
|
62
|
+
* with reach over all of it, holding Use Sequences, in a workspace whose plan
|
|
63
|
+
* includes Sequences — through the same verdicts (`engine/outreach-access.ts`).
|
|
64
|
+
* A plan refuses every row for a member who would be refused, `apply` asks
|
|
65
|
+
* again before it writes, a read answers nothing, and undo stops. Writes go
|
|
66
|
+
* through the store's own adders, as a member's would: reason `manual`,
|
|
67
|
+
* source `member`, the member as `addedByUid`. Each written chunk files one
|
|
68
|
+
* line in the organization's activity log, as the page's add does per entry.
|
|
69
|
+
*/ /** The fields' ids. */ export const OUTREACH_DNC_FIELDS = {
|
|
70
|
+
entry: 'entry',
|
|
71
|
+
kind: 'kind',
|
|
72
|
+
note: 'note',
|
|
73
|
+
reason: 'reason',
|
|
74
|
+
source: 'source',
|
|
75
|
+
addedByUid: 'addedByUid',
|
|
76
|
+
addedAt: 'addedAt',
|
|
77
|
+
sequenceId: 'sequenceId'
|
|
78
|
+
};
|
|
79
|
+
const DOMAIN_ID = 'domain:';
|
|
80
|
+
const ADDRESS_ID = 'address:';
|
|
81
|
+
/** Why a file never changes an entry already on the list. */ export const OUTREACH_DNC_ADD_ONLY_REASON = 'The do-not-contact list only grows from a file: an entry already on it is left as it is.';
|
|
82
|
+
/** The activity lines an import and its undo file, one per chunk written or reverted. */ export const OUTREACH_DO_NOT_CONTACT_IMPORT_ACTIVITY = {
|
|
83
|
+
add: (counts)=>`Imported ${countWords(counts)} to the Sequences do-not-contact list`,
|
|
84
|
+
undo: (counts)=>`Undid an import: removed ${countWords(counts)} from the Sequences do-not-contact list`
|
|
85
|
+
};
|
|
86
|
+
function countWords(counts) {
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (counts.domains) parts.push(`${counts.domains} ${counts.domains === 1 ? 'domain' : 'domains'}`);
|
|
89
|
+
if (counts.addresses) parts.push(`${counts.addresses} ${counts.addresses === 1 ? 'address' : 'addresses'}`);
|
|
90
|
+
return parts.join(' and ');
|
|
91
|
+
}
|
|
92
|
+
/** `getAll` takes this many references at most per call. */ const GET_ALL_CHUNK = 300;
|
|
93
|
+
/** The most rows one export page reads. */ const PAGE_MAX = 1000;
|
|
94
|
+
const PAGE_DEFAULT = 500;
|
|
95
|
+
/** A row is not started with less than this left of the chunk's budget. */ const ROW_BUDGET_MS = 2000;
|
|
96
|
+
/**
|
|
97
|
+
* The entry a cell names, or `null` for a value that is neither an address
|
|
98
|
+
* nor a domain. An address-looking value that is not a usable address is
|
|
99
|
+
* refused rather than read as its domain: a typo in one address must not
|
|
100
|
+
* block a whole company.
|
|
101
|
+
*/ export function outreachDoNotContactTarget(value) {
|
|
102
|
+
let text = String(value != null ? value : '').trim();
|
|
103
|
+
// `Casey Morgan <casey@example.com>`, as a mail client copies it.
|
|
104
|
+
const bracketed = /<([^<>]+)>\s*$/.exec(text);
|
|
105
|
+
if (bracketed) text = bracketed[1].trim();
|
|
106
|
+
text = text.replace(/^mailto:/i, '');
|
|
107
|
+
if (!text) return null;
|
|
108
|
+
const email = normalizeContactEmail(text);
|
|
109
|
+
if (email) {
|
|
110
|
+
const key = outreachDoNotContactKey(email);
|
|
111
|
+
return key ? {
|
|
112
|
+
kind: 'address',
|
|
113
|
+
id: `${ADDRESS_ID}${key}`,
|
|
114
|
+
key,
|
|
115
|
+
email
|
|
116
|
+
} : null;
|
|
117
|
+
}
|
|
118
|
+
if (text.includes('@') && !text.startsWith('@')) return null;
|
|
119
|
+
const domain = normalizeOutreachDomain(text);
|
|
120
|
+
return domain ? {
|
|
121
|
+
kind: 'domain',
|
|
122
|
+
id: `${DOMAIN_ID}${domain}`,
|
|
123
|
+
domain
|
|
124
|
+
} : null;
|
|
125
|
+
}
|
|
126
|
+
/** The entry an Aglyn ID names, or `null`. */ export function outreachDoNotContactTargetFromId(id) {
|
|
127
|
+
const text = String(id != null ? id : '').trim();
|
|
128
|
+
if (text.startsWith(DOMAIN_ID)) {
|
|
129
|
+
const domain = normalizeOutreachDomain(text.slice(DOMAIN_ID.length));
|
|
130
|
+
return domain ? {
|
|
131
|
+
kind: 'domain',
|
|
132
|
+
id: `${DOMAIN_ID}${domain}`,
|
|
133
|
+
domain
|
|
134
|
+
} : null;
|
|
135
|
+
}
|
|
136
|
+
if (text.startsWith(ADDRESS_ID)) {
|
|
137
|
+
const key = text.slice(ADDRESS_ID.length).toLowerCase();
|
|
138
|
+
return /^[0-9a-f]{64}$/.test(key) ? {
|
|
139
|
+
kind: 'address',
|
|
140
|
+
id: `${ADDRESS_ID}${key}`,
|
|
141
|
+
key,
|
|
142
|
+
email: null
|
|
143
|
+
} : null;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/*==========================================
|
|
148
|
+
* THE CATALOG
|
|
149
|
+
*=========================================*/ const ENTRY_GROUP = {
|
|
150
|
+
id: 'entry',
|
|
151
|
+
label: 'Entry'
|
|
152
|
+
};
|
|
153
|
+
const STANDARD_FIELDS = [
|
|
154
|
+
{
|
|
155
|
+
id: OUTREACH_DNC_FIELDS.entry,
|
|
156
|
+
label: 'Entry',
|
|
157
|
+
group: ENTRY_GROUP.id,
|
|
158
|
+
type: 'text',
|
|
159
|
+
required: true,
|
|
160
|
+
matchKey: true,
|
|
161
|
+
maxLength: 320,
|
|
162
|
+
aliases: [
|
|
163
|
+
'email',
|
|
164
|
+
'email address',
|
|
165
|
+
'address',
|
|
166
|
+
'domain',
|
|
167
|
+
'website',
|
|
168
|
+
'do not contact',
|
|
169
|
+
'do-not-contact'
|
|
170
|
+
],
|
|
171
|
+
description: 'An email address or a domain. A domain blocks every address at it. An address is kept as a fingerprint, so it is never exported.'
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: OUTREACH_DNC_FIELDS.note,
|
|
175
|
+
label: 'Note',
|
|
176
|
+
group: ENTRY_GROUP.id,
|
|
177
|
+
type: 'longText',
|
|
178
|
+
maxLength: 500,
|
|
179
|
+
aliases: [
|
|
180
|
+
'detail',
|
|
181
|
+
'details',
|
|
182
|
+
'notes',
|
|
183
|
+
'comment',
|
|
184
|
+
'why'
|
|
185
|
+
],
|
|
186
|
+
description: 'Why it is on the list. Written when the entry is added; an entry already on the list keeps its own.'
|
|
187
|
+
}
|
|
188
|
+
];
|
|
189
|
+
const DERIVED_FIELDS = [
|
|
190
|
+
{
|
|
191
|
+
id: OUTREACH_DNC_FIELDS.kind,
|
|
192
|
+
label: 'Kind',
|
|
193
|
+
group: ENTRY_GROUP.id,
|
|
194
|
+
type: 'text',
|
|
195
|
+
description: 'Domain or Address, from the entry.'
|
|
196
|
+
}
|
|
197
|
+
];
|
|
198
|
+
const SYSTEM_FIELDS = [
|
|
199
|
+
_extends({}, transferIdField(), {
|
|
200
|
+
description: '"domain:" and the domain, or "address:" and the address’s fingerprint.'
|
|
201
|
+
}),
|
|
202
|
+
{
|
|
203
|
+
id: OUTREACH_DNC_FIELDS.reason,
|
|
204
|
+
label: 'Why',
|
|
205
|
+
type: 'text',
|
|
206
|
+
readOnly: true,
|
|
207
|
+
description: 'Why it was added: by a member, a reply asking not to be emailed, an unsubscribe, a bounce, or a mail gateway that blocked the sender.'
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: OUTREACH_DNC_FIELDS.source,
|
|
211
|
+
label: 'Added by',
|
|
212
|
+
type: 'text',
|
|
213
|
+
readOnly: true,
|
|
214
|
+
description: 'A member, or Sequences on its own when a reply, an unsubscribe or a bounce called for it.'
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: OUTREACH_DNC_FIELDS.addedByUid,
|
|
218
|
+
label: 'Added by member (user ID)',
|
|
219
|
+
type: 'text',
|
|
220
|
+
readOnly: true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: OUTREACH_DNC_FIELDS.addedAt,
|
|
224
|
+
label: 'Added',
|
|
225
|
+
type: 'datetime',
|
|
226
|
+
readOnly: true
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: OUTREACH_DNC_FIELDS.sequenceId,
|
|
230
|
+
label: 'Sequence ID',
|
|
231
|
+
type: 'text',
|
|
232
|
+
readOnly: true,
|
|
233
|
+
description: 'The sequence whose email led to the entry, when one did.'
|
|
234
|
+
}
|
|
235
|
+
];
|
|
236
|
+
/** The resource's catalog, the same for every organization. */ export function outreachDoNotContactCatalog() {
|
|
237
|
+
return {
|
|
238
|
+
standard: STANDARD_FIELDS,
|
|
239
|
+
derived: DERIVED_FIELDS,
|
|
240
|
+
system: SYSTEM_FIELDS,
|
|
241
|
+
groups: [
|
|
242
|
+
ENTRY_GROUP
|
|
243
|
+
]
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Note is written with a new entry and never onto one already on the list. */ export const OUTREACH_DNC_LOCKED_RULES = [
|
|
247
|
+
{
|
|
248
|
+
fieldId: OUTREACH_DNC_FIELDS.note,
|
|
249
|
+
reason: OUTREACH_DNC_ADD_ONLY_REASON,
|
|
250
|
+
forced: {
|
|
251
|
+
mode: 'keepExisting',
|
|
252
|
+
blank: 'leave'
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
/** An entry's values by field id, as an export writes them and a plan compares them. */ function entryValues(target, entry) {
|
|
257
|
+
var _OUTREACH_DO_NOT_CONTACT_REASON_LABELS_entry_reason, _OUTREACH_DO_NOT_CONTACT_SOURCE_LABELS_entry_source;
|
|
258
|
+
return {
|
|
259
|
+
[TRANSFER_ID_FIELD]: target.id,
|
|
260
|
+
[OUTREACH_DNC_FIELDS.entry]: target.kind === 'domain' ? target.domain : target.email,
|
|
261
|
+
[OUTREACH_DNC_FIELDS.kind]: target.kind === 'domain' ? 'Domain' : 'Address',
|
|
262
|
+
[OUTREACH_DNC_FIELDS.note]: entry.detail,
|
|
263
|
+
[OUTREACH_DNC_FIELDS.reason]: (_OUTREACH_DO_NOT_CONTACT_REASON_LABELS_entry_reason = OUTREACH_DO_NOT_CONTACT_REASON_LABELS[entry.reason]) != null ? _OUTREACH_DO_NOT_CONTACT_REASON_LABELS_entry_reason : entry.reason,
|
|
264
|
+
[OUTREACH_DNC_FIELDS.source]: (_OUTREACH_DO_NOT_CONTACT_SOURCE_LABELS_entry_source = OUTREACH_DO_NOT_CONTACT_SOURCE_LABELS[entry.source]) != null ? _OUTREACH_DO_NOT_CONTACT_SOURCE_LABELS_entry_source : entry.source,
|
|
265
|
+
[OUTREACH_DNC_FIELDS.addedByUid]: entry.addedByUid,
|
|
266
|
+
[OUTREACH_DNC_FIELDS.addedAt]: entry.addedAtMs ? new Date(entry.addedAtMs).toISOString() : null,
|
|
267
|
+
[OUTREACH_DNC_FIELDS.sequenceId]: entry.sequenceId
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* What an undo compares an added entry with: everything that says who added
|
|
272
|
+
* it and when. An entry taken off and added again since — by a member, or by
|
|
273
|
+
* the sending runtime for a reply or a bounce — no longer matches, and is
|
|
274
|
+
* not the import's to remove.
|
|
275
|
+
*/ const UNDO_FIELDS = [
|
|
276
|
+
OUTREACH_DNC_FIELDS.note,
|
|
277
|
+
OUTREACH_DNC_FIELDS.reason,
|
|
278
|
+
OUTREACH_DNC_FIELDS.source,
|
|
279
|
+
OUTREACH_DNC_FIELDS.addedByUid,
|
|
280
|
+
OUTREACH_DNC_FIELDS.addedAt
|
|
281
|
+
];
|
|
282
|
+
function undoWritten(values) {
|
|
283
|
+
return Object.fromEntries(UNDO_FIELDS.map((fieldId)=>{
|
|
284
|
+
var _values_fieldId;
|
|
285
|
+
return [
|
|
286
|
+
fieldId,
|
|
287
|
+
(_values_fieldId = values[fieldId]) != null ? _values_fieldId : null
|
|
288
|
+
];
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
/** Rows keyed by field id, holding only the fields asked for. */ function pick(values, fieldIds) {
|
|
292
|
+
return Object.fromEntries(fieldIds.map((fieldId)=>{
|
|
293
|
+
var _values_fieldId;
|
|
294
|
+
return [
|
|
295
|
+
fieldId,
|
|
296
|
+
(_values_fieldId = values[fieldId]) != null ? _values_fieldId : null
|
|
297
|
+
];
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
/** The list query the Compliance page's search and filters make, from an export's `filter`. */ function domainListRequest(filter) {
|
|
301
|
+
const clauses = (Array.isArray(filter == null ? void 0 : filter['clauses']) ? filter == null ? void 0 : filter['clauses'] : []).filter((clause)=>!!clause && typeof clause['field'] === 'string' && typeof clause['op'] === 'string' && typeof clause['value'] === 'string').map(({ field, op, value })=>({
|
|
302
|
+
field,
|
|
303
|
+
op,
|
|
304
|
+
value
|
|
305
|
+
}));
|
|
306
|
+
const search = (Array.isArray(filter == null ? void 0 : filter['search']) ? filter == null ? void 0 : filter['search'] : []).filter((word)=>typeof word === 'string');
|
|
307
|
+
return {
|
|
308
|
+
clauses,
|
|
309
|
+
search
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function encodeCursor(cursor) {
|
|
313
|
+
return JSON.stringify(cursor);
|
|
314
|
+
}
|
|
315
|
+
function decodeCursor(cursor) {
|
|
316
|
+
if (!cursor) return null;
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(cursor);
|
|
319
|
+
return parsed && typeof parsed.id === 'string' ? parsed : null;
|
|
320
|
+
} catch (unused) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** The import's record policy: a row that finds an entry is skipped, and no row picks a record to update. */ function addOnlyPolicy(policy) {
|
|
325
|
+
const rows = {};
|
|
326
|
+
for (const [index, override] of Object.entries(policy.rows)){
|
|
327
|
+
rows[Number(index)] = override.action === 'skip' ? {
|
|
328
|
+
action: 'skip'
|
|
329
|
+
} : {};
|
|
330
|
+
}
|
|
331
|
+
return _extends({}, policy, {
|
|
332
|
+
record: _extends({}, policy.record, {
|
|
333
|
+
onMatch: 'skip',
|
|
334
|
+
onAmbiguous: 'skip'
|
|
335
|
+
}),
|
|
336
|
+
rows
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Rows the core matched as new that name one entry between them — `Acme.com`
|
|
341
|
+
* and `www.acme.com`, an address in two spellings — are duplicates of the
|
|
342
|
+
* first: the match key compares the cells, and the entry is what they name.
|
|
343
|
+
*/ function withEntryDuplicates(input) {
|
|
344
|
+
const firstRowOf = new Map();
|
|
345
|
+
return input.rows.map((row, position)=>{
|
|
346
|
+
var _input_matches_position;
|
|
347
|
+
const match = (_input_matches_position = input.matches[position]) != null ? _input_matches_position : {
|
|
348
|
+
kind: 'new'
|
|
349
|
+
};
|
|
350
|
+
const target = outreachDoNotContactTarget(row.values[OUTREACH_DNC_FIELDS.entry]);
|
|
351
|
+
if (!target) return match;
|
|
352
|
+
const first = firstRowOf.get(target.id);
|
|
353
|
+
if (first === undefined) {
|
|
354
|
+
firstRowOf.set(target.id, row.index);
|
|
355
|
+
return match;
|
|
356
|
+
}
|
|
357
|
+
return match.kind === 'new' ? {
|
|
358
|
+
kind: 'duplicateInFile',
|
|
359
|
+
firstRow: first,
|
|
360
|
+
via: {
|
|
361
|
+
fieldId: OUTREACH_DNC_FIELDS.entry,
|
|
362
|
+
value: target.kind === 'domain' ? target.domain : String(target.email)
|
|
363
|
+
}
|
|
364
|
+
} : match;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
function summaryOf(rows) {
|
|
368
|
+
const summary = {
|
|
369
|
+
create: 0,
|
|
370
|
+
update: 0,
|
|
371
|
+
unchanged: 0,
|
|
372
|
+
skip: 0,
|
|
373
|
+
fail: 0,
|
|
374
|
+
total: rows.length
|
|
375
|
+
};
|
|
376
|
+
for (const row of rows)summary[row.verdict] += 1;
|
|
377
|
+
return summary;
|
|
378
|
+
}
|
|
379
|
+
/** The value a planned create writes to a field. */ function plannedValue(row, fieldId) {
|
|
380
|
+
var _ref;
|
|
381
|
+
var _row_diff_find;
|
|
382
|
+
return (_ref = (_row_diff_find = row.diff.find((change)=>change.fieldId === fieldId)) == null ? void 0 : _row_diff_find.after) != null ? _ref : null;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The do-not-contact list's server half. Specs pass an in-memory Firestore;
|
|
386
|
+
* the console's declarations pass the Admin SDK's and the platform's
|
|
387
|
+
* membership, organization and activity readers.
|
|
388
|
+
*/ export function createOutreachDoNotContactTransferResource(deps) {
|
|
389
|
+
var _deps_now;
|
|
390
|
+
const { firestore } = deps;
|
|
391
|
+
const now = (_deps_now = deps.now) != null ? _deps_now : Date.now;
|
|
392
|
+
/** The sentence a member is refused with, or `null` when they may work the list. */ async function accessRefusal(ctx) {
|
|
393
|
+
var _ref;
|
|
394
|
+
var _outreachEntitlementRefusal;
|
|
395
|
+
if (!ctx.actorUid) return 'Only a signed-in member can change the do-not-contact list.';
|
|
396
|
+
const membership = await deps.resolveOrgPermissions(ctx.actorUid, {
|
|
397
|
+
orgId: ctx.orgId
|
|
398
|
+
});
|
|
399
|
+
const refused = outreachMembershipRefusal(membership, ctx.orgId);
|
|
400
|
+
if (refused) return refused.message;
|
|
401
|
+
return (_ref = (_outreachEntitlementRefusal = outreachEntitlementRefusal(await deps.readOrg(ctx.orgId))) == null ? void 0 : _outreachEntitlementRefusal.message) != null ? _ref : null;
|
|
402
|
+
}
|
|
403
|
+
/** The entries the targets name, by Aglyn ID; absent for one not on the list. */ async function readEntries(orgId, targets) {
|
|
404
|
+
const found = new Map();
|
|
405
|
+
const unique = [
|
|
406
|
+
...new Map(targets.map((target)=>[
|
|
407
|
+
target.id,
|
|
408
|
+
target
|
|
409
|
+
])).values()
|
|
410
|
+
];
|
|
411
|
+
const addresses = outreachDoNotContactCollection(firestore, orgId);
|
|
412
|
+
const domains = outreachDoNotContactDomainCollection(firestore, orgId);
|
|
413
|
+
for(let start = 0; start < unique.length; start += GET_ALL_CHUNK){
|
|
414
|
+
const chunk = unique.slice(start, start + GET_ALL_CHUNK);
|
|
415
|
+
const snapshots = await firestore.getAll(...chunk.map((target)=>target.kind === 'domain' ? domains.doc(target.domain) : addresses.doc(target.key)));
|
|
416
|
+
chunk.forEach((target, index)=>{
|
|
417
|
+
const snapshot = snapshots[index];
|
|
418
|
+
if (!(snapshot == null ? void 0 : snapshot.exists)) return;
|
|
419
|
+
const data = snapshot.data();
|
|
420
|
+
const entry = target.kind === 'domain' ? readOutreachDoNotContactDomainEntry(target.domain, data) : readOutreachDoNotContactEntry(target.key, data);
|
|
421
|
+
if (!entry) return;
|
|
422
|
+
const { reason, source, addedByUid, addedAtMs, enrollmentId, sequenceId, detail } = entry;
|
|
423
|
+
found.set(target.id, {
|
|
424
|
+
reason,
|
|
425
|
+
source,
|
|
426
|
+
addedByUid,
|
|
427
|
+
addedAtMs,
|
|
428
|
+
enrollmentId,
|
|
429
|
+
sequenceId,
|
|
430
|
+
detail
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return found;
|
|
435
|
+
}
|
|
436
|
+
/** The Compliance page's domains query, as one Admin query in its own order. */ function domainQuery(orgId, plan) {
|
|
437
|
+
const query = applyListQuery(outreachDoNotContactDomainCollection(firestore, orgId), plan);
|
|
438
|
+
// A date filter orders by when an entry was added; the id breaks ties so
|
|
439
|
+
// a page boundary never splits or repeats a moment.
|
|
440
|
+
return plan.orderBy.path === LIST_QUERY_ID_PATH ? query : query.orderBy(FieldPath.documentId(), plan.orderBy.direction);
|
|
441
|
+
}
|
|
442
|
+
/** Whether an export may read: org-wide members who may work the list. */ async function mayRead(ctx, options) {
|
|
443
|
+
// The list covers the whole organization; a reader scoped to some sites
|
|
444
|
+
// is refused by the route gate, and here reads nothing.
|
|
445
|
+
if (options == null ? void 0 : options.scopeTokens) return false;
|
|
446
|
+
return await accessRefusal(ctx) === null;
|
|
447
|
+
}
|
|
448
|
+
/** The selected domains, in id order; an address id names nothing an export can read. */ function selectedDomains(ids) {
|
|
449
|
+
const targets = ids.map(outreachDoNotContactTargetFromId).filter((target)=>(target == null ? void 0 : target.kind) === 'domain');
|
|
450
|
+
return [
|
|
451
|
+
...new Map(targets.map((target)=>[
|
|
452
|
+
target.id,
|
|
453
|
+
target
|
|
454
|
+
])).values()
|
|
455
|
+
].sort((a, b)=>a.domain.localeCompare(b.domain));
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
fields: ()=>outreachDoNotContactCatalog(),
|
|
459
|
+
matchKeys: OUTREACH_DNC_MATCH_KEYS,
|
|
460
|
+
lockedRules: ()=>OUTREACH_DNC_LOCKED_RULES,
|
|
461
|
+
async count (ctx, options) {
|
|
462
|
+
if (!await mayRead(ctx, options)) return 0;
|
|
463
|
+
if (options.ids) {
|
|
464
|
+
return (await readEntries(ctx.orgId, selectedDomains(options.ids))).size;
|
|
465
|
+
}
|
|
466
|
+
const plan = planListQuery(OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY, domainListRequest(options.filter), nameSearchNormalizers);
|
|
467
|
+
const counted = await applyListQuery(outreachDoNotContactDomainCollection(firestore, ctx.orgId), plan).count().get();
|
|
468
|
+
return counted.data().count;
|
|
469
|
+
},
|
|
470
|
+
async readPage (ctx, cursor, fieldIds, options) {
|
|
471
|
+
var _ref, _last_get;
|
|
472
|
+
if (!await mayRead(ctx, options)) return {
|
|
473
|
+
rows: [],
|
|
474
|
+
next: null
|
|
475
|
+
};
|
|
476
|
+
const size = Math.min(PAGE_MAX, Math.max(1, Math.floor((_ref = options == null ? void 0 : options.pageSize) != null ? _ref : PAGE_DEFAULT)));
|
|
477
|
+
if (options == null ? void 0 : options.ids) {
|
|
478
|
+
const domains = selectedDomains(options.ids);
|
|
479
|
+
const start = Math.max(0, Number(cursor != null ? cursor : 0) || 0);
|
|
480
|
+
const slice = domains.slice(start, start + size);
|
|
481
|
+
const found = await readEntries(ctx.orgId, slice);
|
|
482
|
+
const rows = slice.flatMap((target)=>{
|
|
483
|
+
const entry = found.get(target.id);
|
|
484
|
+
return entry ? [
|
|
485
|
+
pick(entryValues(target, entry), fieldIds)
|
|
486
|
+
] : [];
|
|
487
|
+
});
|
|
488
|
+
return {
|
|
489
|
+
rows,
|
|
490
|
+
next: start + size < domains.length ? String(start + size) : null
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
const plan = planListQuery(OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY, domainListRequest(options == null ? void 0 : options.filter), nameSearchNormalizers);
|
|
494
|
+
let query = domainQuery(ctx.orgId, plan);
|
|
495
|
+
const after = decodeCursor(cursor);
|
|
496
|
+
if (after) {
|
|
497
|
+
var _after_value;
|
|
498
|
+
query = plan.orderBy.path === LIST_QUERY_ID_PATH ? query.startAfter(after.id) : query.startAfter((_after_value = after.value) != null ? _after_value : null, after.id);
|
|
499
|
+
}
|
|
500
|
+
const snapshot = await query.limit(size + 1).get();
|
|
501
|
+
const docs = snapshot.docs.slice(0, size);
|
|
502
|
+
const rows = docs.flatMap((doc)=>{
|
|
503
|
+
const entry = readOutreachDoNotContactDomainEntry(doc.id, doc.data());
|
|
504
|
+
if (!entry) return [];
|
|
505
|
+
const { domain } = entry, rest = _object_without_properties_loose(entry, [
|
|
506
|
+
"domain"
|
|
507
|
+
]);
|
|
508
|
+
return [
|
|
509
|
+
pick(entryValues({
|
|
510
|
+
kind: 'domain',
|
|
511
|
+
id: `${DOMAIN_ID}${doc.id}`,
|
|
512
|
+
domain
|
|
513
|
+
}, rest), fieldIds)
|
|
514
|
+
];
|
|
515
|
+
});
|
|
516
|
+
const last = docs[docs.length - 1];
|
|
517
|
+
const next = snapshot.docs.length > size && last ? encodeCursor(_extends({
|
|
518
|
+
id: last.id
|
|
519
|
+
}, plan.orderBy.path === LIST_QUERY_ID_PATH ? {} : {
|
|
520
|
+
value: (_last_get = last.get(plan.orderBy.path)) != null ? _last_get : null
|
|
521
|
+
})) : null;
|
|
522
|
+
return {
|
|
523
|
+
rows,
|
|
524
|
+
next
|
|
525
|
+
};
|
|
526
|
+
},
|
|
527
|
+
async lookup (ctx, requests) {
|
|
528
|
+
const lookup = new Map();
|
|
529
|
+
const records = new Map();
|
|
530
|
+
// A member who may not work the list learns nothing about what is on it.
|
|
531
|
+
if (await accessRefusal(ctx)) return {
|
|
532
|
+
lookup,
|
|
533
|
+
records
|
|
534
|
+
};
|
|
535
|
+
const asked = [];
|
|
536
|
+
for (const request of requests){
|
|
537
|
+
for (const value of request.values){
|
|
538
|
+
const target = request.fieldId === TRANSFER_ID_FIELD ? outreachDoNotContactTargetFromId(value) : request.fieldId === OUTREACH_DNC_FIELDS.entry ? outreachDoNotContactTarget(value) : null;
|
|
539
|
+
if (target) asked.push({
|
|
540
|
+
at: matchLookupKey(request.fieldId, value),
|
|
541
|
+
target
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const found = await readEntries(ctx.orgId, asked.map((entry)=>entry.target));
|
|
546
|
+
for (const { at, target } of asked){
|
|
547
|
+
const entry = found.get(target.id);
|
|
548
|
+
if (!entry) continue;
|
|
549
|
+
lookup.set(at, [
|
|
550
|
+
target.id
|
|
551
|
+
]);
|
|
552
|
+
// An address read by its id has no address to show; one asked for
|
|
553
|
+
// by its address does.
|
|
554
|
+
const known = records.get(target.id);
|
|
555
|
+
if (!known || known[OUTREACH_DNC_FIELDS.entry] == null && target.kind === 'address' && target.email) {
|
|
556
|
+
records.set(target.id, entryValues(target, entry));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
lookup,
|
|
561
|
+
records
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
async plan (ctx, input) {
|
|
565
|
+
const policy = addOnlyPolicy(input.policy);
|
|
566
|
+
const built = buildTransferPlan(_extends({}, input, {
|
|
567
|
+
policy,
|
|
568
|
+
matches: withEntryDuplicates(input)
|
|
569
|
+
}));
|
|
570
|
+
// Defensive: whatever the policy said, nothing on the list is rewritten.
|
|
571
|
+
const rows = built.rows.map((row)=>row.verdict === 'update' ? _extends({}, row, {
|
|
572
|
+
verdict: 'skip',
|
|
573
|
+
reason: 'matchedSkipped',
|
|
574
|
+
diff: []
|
|
575
|
+
}) : row);
|
|
576
|
+
const plan = _extends({}, built, {
|
|
577
|
+
rows,
|
|
578
|
+
summary: summaryOf(rows)
|
|
579
|
+
});
|
|
580
|
+
const refusal = await accessRefusal(ctx);
|
|
581
|
+
if (refusal) {
|
|
582
|
+
return withTransferResourceFindings(plan, plan.rows.map((row)=>({
|
|
583
|
+
row: row.index,
|
|
584
|
+
detail: refusal,
|
|
585
|
+
refuse: true
|
|
586
|
+
})));
|
|
587
|
+
}
|
|
588
|
+
const findings = [];
|
|
589
|
+
const creating = [];
|
|
590
|
+
for (const row of plan.rows){
|
|
591
|
+
if (row.verdict !== 'create') continue;
|
|
592
|
+
const value = plannedValue(row, OUTREACH_DNC_FIELDS.entry);
|
|
593
|
+
const target = outreachDoNotContactTarget(value);
|
|
594
|
+
if (!target) {
|
|
595
|
+
findings.push({
|
|
596
|
+
row: row.index,
|
|
597
|
+
fieldId: OUTREACH_DNC_FIELDS.entry,
|
|
598
|
+
value: String(value != null ? value : ''),
|
|
599
|
+
detail: `"${String(value != null ? value : '')}" is neither an email address nor a domain.`,
|
|
600
|
+
refuse: true
|
|
601
|
+
});
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
creating.push({
|
|
605
|
+
row,
|
|
606
|
+
target
|
|
607
|
+
});
|
|
608
|
+
if (target.kind === 'domain' && isPublicMailboxDomain(target.domain)) {
|
|
609
|
+
findings.push({
|
|
610
|
+
row: row.index,
|
|
611
|
+
fieldId: OUTREACH_DNC_FIELDS.entry,
|
|
612
|
+
value: target.domain,
|
|
613
|
+
detail: `${target.domain} is a public mailbox provider: adding it blocks every address at ${target.domain}, not one person.`
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// An address whose domain is listed, or is added by this file, is covered already.
|
|
618
|
+
const fileDomains = new Map();
|
|
619
|
+
for (const { row, target } of creating){
|
|
620
|
+
if (target.kind === 'domain' && !fileDomains.has(target.domain)) fileDomains.set(target.domain, row.index);
|
|
621
|
+
}
|
|
622
|
+
const addresses = creating.filter((entry)=>entry.target.kind === 'address');
|
|
623
|
+
const listed = addresses.length ? await lookupOutreachDoNotContactDomains(firestore, ctx.orgId, addresses.map((entry)=>String(entry.target.email))) : new Map();
|
|
624
|
+
for (const { row, target } of addresses){
|
|
625
|
+
const domain = outreachEmailDomain(target.email);
|
|
626
|
+
if (!domain) continue;
|
|
627
|
+
const inFile = fileDomains.get(domain);
|
|
628
|
+
if (inFile !== undefined) {
|
|
629
|
+
findings.push({
|
|
630
|
+
row: row.index,
|
|
631
|
+
fieldId: OUTREACH_DNC_FIELDS.entry,
|
|
632
|
+
value: String(target.email),
|
|
633
|
+
detail: `Its domain, ${domain}, is added by row ${inFile + 1} of this file, which already covers this address.`
|
|
634
|
+
});
|
|
635
|
+
} else if (listed.get(String(target.email)) === true) {
|
|
636
|
+
findings.push({
|
|
637
|
+
row: row.index,
|
|
638
|
+
fieldId: OUTREACH_DNC_FIELDS.entry,
|
|
639
|
+
value: String(target.email),
|
|
640
|
+
detail: `Its domain, ${domain}, is already on the list, which already covers this address.`
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return withTransferResourceFindings(plan, findings);
|
|
645
|
+
},
|
|
646
|
+
async apply (ctx, chunk, writer) {
|
|
647
|
+
const results = [];
|
|
648
|
+
const undo = [];
|
|
649
|
+
const added = {
|
|
650
|
+
domains: 0,
|
|
651
|
+
addresses: 0
|
|
652
|
+
};
|
|
653
|
+
const refusal = await accessRefusal(ctx);
|
|
654
|
+
for (const row of chunk.rows){
|
|
655
|
+
const before = await writer.alreadyApplied(row.index);
|
|
656
|
+
if (before) {
|
|
657
|
+
results.push(before);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (writer.timeLeftMs() < ROW_BUDGET_MS) break;
|
|
661
|
+
let result;
|
|
662
|
+
let entry;
|
|
663
|
+
const target = outreachDoNotContactTarget(plannedValue(row, OUTREACH_DNC_FIELDS.entry));
|
|
664
|
+
if (row.verdict !== 'create') {
|
|
665
|
+
// Only a create is planned to write; nothing on the list is rewritten.
|
|
666
|
+
result = _extends({
|
|
667
|
+
row: row.index,
|
|
668
|
+
outcome: 'skipped'
|
|
669
|
+
}, row.recordId ? {
|
|
670
|
+
recordId: row.recordId
|
|
671
|
+
} : {}, {
|
|
672
|
+
reason: 'matchedSkipped'
|
|
673
|
+
});
|
|
674
|
+
} else if (refusal) {
|
|
675
|
+
result = {
|
|
676
|
+
row: row.index,
|
|
677
|
+
outcome: 'failed',
|
|
678
|
+
reason: 'resourceRule',
|
|
679
|
+
message: refusal
|
|
680
|
+
};
|
|
681
|
+
} else if (!target) {
|
|
682
|
+
result = {
|
|
683
|
+
row: row.index,
|
|
684
|
+
outcome: 'failed',
|
|
685
|
+
reason: 'resourceRule',
|
|
686
|
+
message: 'Neither an email address nor a domain.'
|
|
687
|
+
};
|
|
688
|
+
} else {
|
|
689
|
+
const note = plannedValue(row, OUTREACH_DNC_FIELDS.note);
|
|
690
|
+
const common = {
|
|
691
|
+
orgId: ctx.orgId,
|
|
692
|
+
reason: 'manual',
|
|
693
|
+
source: 'member',
|
|
694
|
+
addedByUid: ctx.actorUid,
|
|
695
|
+
nowMs: now(),
|
|
696
|
+
detail: typeof note === 'string' ? note : null
|
|
697
|
+
};
|
|
698
|
+
const written = target.kind === 'domain' ? await addOutreachDoNotContactDomain(firestore, _extends({}, common, {
|
|
699
|
+
domain: target.domain
|
|
700
|
+
})) : await addOutreachDoNotContact(firestore, _extends({}, common, {
|
|
701
|
+
email: String(target.email)
|
|
702
|
+
}));
|
|
703
|
+
if (written.created) {
|
|
704
|
+
result = {
|
|
705
|
+
row: row.index,
|
|
706
|
+
outcome: 'created',
|
|
707
|
+
recordId: target.id
|
|
708
|
+
};
|
|
709
|
+
entry = {
|
|
710
|
+
row: row.index,
|
|
711
|
+
recordId: target.id,
|
|
712
|
+
action: 'created',
|
|
713
|
+
written: undoWritten(entryValues(target, written.entry))
|
|
714
|
+
};
|
|
715
|
+
added[target.kind === 'domain' ? 'domains' : 'addresses'] += 1;
|
|
716
|
+
} else {
|
|
717
|
+
// Added since the plan was made: the entry already there stands.
|
|
718
|
+
result = {
|
|
719
|
+
row: row.index,
|
|
720
|
+
outcome: 'unchanged',
|
|
721
|
+
recordId: target.id,
|
|
722
|
+
reason: 'matchedSkipped'
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
await writer.markApplied(result, entry);
|
|
727
|
+
results.push(result);
|
|
728
|
+
if (entry) undo.push(entry);
|
|
729
|
+
}
|
|
730
|
+
if (added.domains || added.addresses) {
|
|
731
|
+
await deps.logOrgActivity(ctx.orgId, {
|
|
732
|
+
uid: ctx.actorUid
|
|
733
|
+
}, OUTREACH_DO_NOT_CONTACT_IMPORT_ACTIVITY.add(added), {
|
|
734
|
+
type: 'org',
|
|
735
|
+
id: ctx.orgId
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
results,
|
|
740
|
+
undo
|
|
741
|
+
};
|
|
742
|
+
},
|
|
743
|
+
async revert (ctx, snapshot, decisions) {
|
|
744
|
+
const refusal = await accessRefusal(ctx);
|
|
745
|
+
if (refusal) throw new Error(refusal);
|
|
746
|
+
const targets = snapshot.entries.map((entry)=>outreachDoNotContactTargetFromId(entry.recordId)).filter((target)=>target !== null);
|
|
747
|
+
const found = await readEntries(ctx.orgId, targets);
|
|
748
|
+
const done = [];
|
|
749
|
+
const conflicts = [];
|
|
750
|
+
const removed = {
|
|
751
|
+
domains: 0,
|
|
752
|
+
addresses: 0
|
|
753
|
+
};
|
|
754
|
+
for (const entry of snapshot.entries){
|
|
755
|
+
const target = outreachDoNotContactTargetFromId(entry.recordId);
|
|
756
|
+
const stored = target ? found.get(target.id) : undefined;
|
|
757
|
+
if (!target || !stored) {
|
|
758
|
+
done.push({
|
|
759
|
+
action: 'nothing',
|
|
760
|
+
recordId: entry.recordId,
|
|
761
|
+
why: 'gone'
|
|
762
|
+
});
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const step = planTransferUndo(entry, entryValues(target, stored));
|
|
766
|
+
if (step.action === 'conflict') {
|
|
767
|
+
// Taken off and added again since. The person may still choose to
|
|
768
|
+
// remove a member's re-add; an entry Sequences added on its own —
|
|
769
|
+
// someone replied, unsubscribed or bounced — is never the import's.
|
|
770
|
+
if ((decisions == null ? void 0 : decisions[entry.recordId]) !== 'revert' || stored.source === 'runtime') {
|
|
771
|
+
conflicts.push(step);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
} else if (step.action !== 'delete') {
|
|
775
|
+
done.push(step);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const gone = target.kind === 'domain' ? await removeOutreachDoNotContactDomain(firestore, ctx.orgId, target.domain) : await removeOutreachDoNotContactEntry(firestore, ctx.orgId, target.key);
|
|
779
|
+
if (gone) removed[target.kind === 'domain' ? 'domains' : 'addresses'] += 1;
|
|
780
|
+
done.push({
|
|
781
|
+
action: 'delete',
|
|
782
|
+
recordId: entry.recordId
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
if (removed.domains || removed.addresses) {
|
|
786
|
+
await deps.logOrgActivity(ctx.orgId, {
|
|
787
|
+
uid: ctx.actorUid
|
|
788
|
+
}, OUTREACH_DO_NOT_CONTACT_IMPORT_ACTIVITY.undo(removed), {
|
|
789
|
+
type: 'org',
|
|
790
|
+
id: ctx.orgId
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
done,
|
|
795
|
+
conflicts
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
//# sourceMappingURL=do-not-contact-transfer.js.map
|