@aglyn/plugins-email 1.0.0-beta.221 → 1.0.0-beta.223
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/README.md +5 -4
- package/package.json +16 -17
- package/src/lib/components/email-template-detail.d.ts +5 -13
- package/src/lib/components/email-template-detail.js +29 -426
- package/src/lib/components/email-template-detail.js.map +1 -1
- package/src/lib/components/email-zones.d.ts +15 -0
- package/src/lib/components/email-zones.js +1 -0
- package/src/lib/components/email-zones.js.map +1 -1
- package/src/lib/components/list-detail-card.d.ts +0 -36
- package/src/lib/components/list-detail-card.js +125 -84
- package/src/lib/components/list-detail-card.js.map +1 -1
- package/src/lib/components/list-members-panel.d.ts +17 -0
- package/src/lib/components/list-members-panel.js +14 -0
- package/src/lib/components/list-members-panel.js.map +1 -1
- package/src/lib/components/site-package-email-preview.d.ts +13 -0
- package/src/lib/components/site-package-email-preview.js +47 -0
- package/src/lib/components/site-package-email-preview.js.map +1 -0
- package/src/lib/components/suppressions-card.js +75 -6
- package/src/lib/components/suppressions-card.js.map +1 -1
- package/src/lib/components/use-org-email-topics.js +2 -9
- package/src/lib/components/use-org-email-topics.js.map +1 -1
- package/src/lib/constants/site-package.d.ts +21 -0
- package/src/lib/constants/site-package.js +21 -0
- package/src/lib/constants/site-package.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +12 -0
- package/src/lib/declarations.console-server.js +73 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/list-import.d.ts +64 -75
- package/src/lib/list-import.js +43 -158
- package/src/lib/list-import.js.map +1 -1
- package/src/lib/model/email-topic-catalog.d.ts +24 -0
- package/src/lib/model/email-topic-catalog.js +19 -0
- package/src/lib/model/email-topic-catalog.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -25
- package/src/lib/model/index.js +0 -27
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.d.ts +8 -0
- package/src/lib/plugin.js +76 -2
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/person-eraser.d.ts +39 -0
- package/src/lib/server/person-eraser.js +44 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server-console.js +9 -9
- package/src/lib/server-console.js.map +1 -1
- package/src/lib/server-list-gate.d.ts +7 -0
- package/src/lib/server-list-gate.js +10 -3
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server-list-import.d.ts +20 -177
- package/src/lib/server-list-import.js +34 -594
- package/src/lib/server-list-import.js.map +1 -1
- package/src/lib/server.js +16 -29
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-send-tally.d.ts +29 -0
- package/src/lib/testing/stand-in-send-tally.js +52 -0
- package/src/lib/testing/stand-in-send-tally.js.map +1 -0
- package/src/lib/transfer/email-transfer-catalog.d.ts +151 -0
- package/src/lib/transfer/email-transfer-catalog.js +536 -0
- package/src/lib/transfer/email-transfer-catalog.js.map +1 -0
- package/src/lib/transfer/list-import-steps.d.ts +44 -0
- package/src/lib/transfer/list-import-steps.js +201 -0
- package/src/lib/transfer/list-import-steps.js.map +1 -0
- package/src/lib/transfer/list-members.server.d.ts +165 -0
- package/src/lib/transfer/list-members.server.js +911 -0
- package/src/lib/transfer/list-members.server.js.map +1 -0
- package/src/lib/transfer/suppressions.server.d.ts +69 -0
- package/src/lib/transfer/suppressions.server.js +282 -0
- package/src/lib/transfer/suppressions.server.js.map +1 -0
- package/src/lib/transfer/topics-package.d.ts +56 -0
- package/src/lib/transfer/topics-package.js +87 -0
- package/src/lib/transfer/topics-package.js.map +1 -0
- package/src/lib/transfer/topics-package.server.d.ts +33 -0
- package/src/lib/transfer/topics-package.server.js +171 -0
- package/src/lib/transfer/topics-package.server.js.map +1 -0
- package/src/lib/transfer/transfer-reads.server.d.ts +25 -0
- package/src/lib/transfer/transfer-reads.server.js +34 -0
- package/src/lib/transfer/transfer-reads.server.js.map +1 -0
- package/src/lib/components/list-import-drawer.d.ts +0 -22
- package/src/lib/components/list-import-drawer.js +0 -662
- package/src/lib/components/list-import-drawer.js.map +0 -1
- package/src/lib/model/template-report.d.ts +0 -158
- package/src/lib/model/template-report.js +0 -249
- package/src/lib/model/template-report.js.map +0 -1
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* ONE LIST'S MEMBERS, IMPORTED AND EXPORTED (AGL-3529) — the server half of
|
|
19
|
+
* `email.list-members` on the platform's transfer framework.
|
|
20
|
+
*
|
|
21
|
+
* The framework reads the file, maps its columns, finds the rows that repeat
|
|
22
|
+
* an address, plans every row before anything is written, applies the plan
|
|
23
|
+
* a chunk at a time through a ledger that never writes a row twice, resumes
|
|
24
|
+
* where a closed tab stopped, and keeps an undo for seven days. What it
|
|
25
|
+
* cannot know is a list's rules, and every one of them the importer this
|
|
26
|
+
* replaces kept is kept here:
|
|
27
|
+
*
|
|
28
|
+
* - EVERY ADDRESS GOES THROUGH THE CHECKS A TYPED ONE DOES — the same
|
|
29
|
+
* functions, not similar ones. `resolveAddresses` is the resolution the
|
|
30
|
+
* audience card's add runs, `assignmentBasis` is the policy it applies,
|
|
31
|
+
* and `enrollListMember` is the one writer of the membership collection.
|
|
32
|
+
* Suppressed people and people who said no are refused at the write, with
|
|
33
|
+
* the reason, whatever the file or the operator says.
|
|
34
|
+
* - THE STATEMENT OF PERMISSION is the plugin's own wizard step. Its answer
|
|
35
|
+
* reaches the dry run in `extras`; the dry run records WHO made it on the
|
|
36
|
+
* list's import ledger (`orgs/{orgId}/lists/{listId}/imports/{jobId}`),
|
|
37
|
+
* and every write reads the attester from there — never from whoever
|
|
38
|
+
* pressed Resume, and never from the browser. Without it only people with
|
|
39
|
+
* an opt-in on record are added. Neither the statement nor anything in a
|
|
40
|
+
* file can make a refusal an opt-in (`CONSENT_FROM_FILE_REASON`).
|
|
41
|
+
* - THE SCREENING — role accounts and column names that read as a bought or
|
|
42
|
+
* appended list — and a consent check of a bounded sample are a warning on
|
|
43
|
+
* the dry run that has to be acknowledged before anything is written, and
|
|
44
|
+
* the sample is shown again on the permission step.
|
|
45
|
+
* - SOMEBODY ALREADY ON THE LIST is unchanged: their membership is never
|
|
46
|
+
* re-enrolled or rewritten. Their contact details change only when the
|
|
47
|
+
* operator says so on the "People already on this list" step.
|
|
48
|
+
* - A FILE'S CONTACT COLUMNS go to the contact record through the platform's
|
|
49
|
+
* contact-capture door (`capturePluginContact`), the record system's own
|
|
50
|
+
* write path — never written here. Each is planned against what the record
|
|
51
|
+
* holds, under the person's field choices; a blank cell never empties one.
|
|
52
|
+
* - 50,000 rows a file (`limits.maxRows`), refused at the upload before
|
|
53
|
+
* anything is staged; repeats of an address are skipped as repeats; a line
|
|
54
|
+
* that is not an address fails its row, by name, rather than vanishing.
|
|
55
|
+
*
|
|
56
|
+
* ## Who may
|
|
57
|
+
*
|
|
58
|
+
* The transfer gate admits a member who may manage the SITE's data. A list
|
|
59
|
+
* is the organization's, and every site in it can mail one, so changing who
|
|
60
|
+
* is on it — or reading their consent through the matching — needs an
|
|
61
|
+
* org-wide owner, admin or editor (`isOrgWideListWriter`), the rule the
|
|
62
|
+
* list routes apply. An export reads through `scopeTokens`: a collaborator
|
|
63
|
+
* scoped to some sites reads no member at all, which is what the rules
|
|
64
|
+
* would answer them.
|
|
65
|
+
*/ import { ASSIGNMENT_REFUSAL_MESSAGES, assignmentBasis, MARKETING_CONSENT_BASIS_FIELD, MARKETING_CONSENT_BY_HOST_FIELD, normalizeContactEmail, readMarketingBasis } from "@aglyn/aglyn/server";
|
|
66
|
+
import { composeContactName, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
|
|
67
|
+
import { TRANSFER_DRAFT_RETENTION_MS, TRANSFER_FILE_SAMPLE_ROW, TRANSFER_ID_FIELD, TRANSFER_UNDO_WINDOW_MS, TRANSFER_WARNING_CLASSES, buildTransferPlan, matchLookupKey, normalizeMatchValue, planTransferUndo } from "@aglyn/aglyn/data-transfer";
|
|
68
|
+
import { capturePluginContact } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
|
|
69
|
+
import { consentGroupForSite, enrollListMember, firebaseAdmin, orgDataCollectionForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
70
|
+
import { TransferEngineError } from "@aglyn/tenant-data-admin/server/transfer-jobs";
|
|
71
|
+
import { FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
72
|
+
import { importedBasisReason, isRoleAccount, purchaseTellColumns } from "../list-import.js";
|
|
73
|
+
import { isOrgWideListWriter, LIST_MEMBER_BATCH_MAX, resolveAddresses } from "../server-list-gate.js";
|
|
74
|
+
import { LIST_MEMBER_ALIASES, LIST_MEMBER_CONTACT_FIELD_IDS, LIST_MEMBER_LOCKED_RULES, LIST_MEMBER_MATCH_KEYS, contactFieldKey, listIdOfResourceKey, listMemberCatalog, readListConsentAnswer, readListExistingAnswer } from "./email-transfer-catalog.js";
|
|
75
|
+
import { countExisting, slices } from "./transfer-reads.server.js";
|
|
76
|
+
/** `source` stamped on every membership an import writes. */ export const CONSOLE_IMPORT_SOURCE = 'console:list-import';
|
|
77
|
+
/** A list's import ledger: `orgs/{orgId}/lists/{listId}/imports/{jobId}`. */ export const LIST_IMPORTS_SUBCOLLECTION = 'imports';
|
|
78
|
+
/**
|
|
79
|
+
* How long a list's import ledger is kept (AGL-3549): it holds sample
|
|
80
|
+
* shared-mailbox addresses and who stated permission, and is read only
|
|
81
|
+
* while its job can still write — the longest a planned import may wait
|
|
82
|
+
* to be applied (`TRANSFER_DRAFT_RETENTION_MS`, after which the job itself
|
|
83
|
+
* expires), its undo window, and a day. The Firestore TTL policy on
|
|
84
|
+
* `imports.expiresAt` deletes it; nothing else does.
|
|
85
|
+
*/ export const LIST_IMPORT_LEDGER_RETENTION_MS = TRANSFER_DRAFT_RETENTION_MS + TRANSFER_UNDO_WINDOW_MS + 24 * 60 * 60 * 1000;
|
|
86
|
+
/** The `expiresAt` of a list import ledger written at `nowMs`. */ export function listImportLedgerExpiry(nowMs) {
|
|
87
|
+
return Timestamp.fromMillis(nowMs + LIST_IMPORT_LEDGER_RETENTION_MS);
|
|
88
|
+
}
|
|
89
|
+
/** Role accounts kept verbatim on the ledger, so the step names some of them. */ const ROLE_ACCOUNT_SAMPLE_MAX = 25;
|
|
90
|
+
/** How many addresses one `in` query may carry. */ const IN_QUERY_MAX = 30;
|
|
91
|
+
/** Reads running at once. */ const READ_CONCURRENCY = 8;
|
|
92
|
+
/** Below this much of a chunk's budget, a write stops at a row boundary. */ const APPLY_RESERVE_MS = 3000;
|
|
93
|
+
/** Why a contact detail stays as the record holds it. */ export const CONTACTS_KEPT_REASON = 'You chose to leave the contact details of people the workspace already holds as they are.';
|
|
94
|
+
/**
|
|
95
|
+
* The list a request names, after proving the asker may change who is on
|
|
96
|
+
* it. `write: false` is an export read: the route has already scoped it.
|
|
97
|
+
*/ export async function listTargetFor(ctx, options) {
|
|
98
|
+
var _ctx_hostId, _listIdOfResourceKey, _list_get;
|
|
99
|
+
const hostId = (_ctx_hostId = ctx.hostId) != null ? _ctx_hostId : '';
|
|
100
|
+
const listId = (_listIdOfResourceKey = listIdOfResourceKey(ctx.resource)) != null ? _listIdOfResourceKey : '';
|
|
101
|
+
if (!hostId || !listId) {
|
|
102
|
+
throw new TransferEngineError('invalid', 400, 'Open this from the list it belongs to.');
|
|
103
|
+
}
|
|
104
|
+
if (options.write) {
|
|
105
|
+
const membership = ctx.actorUid ? await resolveOrgMembership(ctx.actorUid, ctx.orgId).catch(()=>null) : null;
|
|
106
|
+
if (!isOrgWideListWriter(membership == null ? void 0 : membership.member)) {
|
|
107
|
+
throw new TransferEngineError('forbidden', 403, 'Lists belong to the whole organization, so changing who is on one needs organization-wide access rather than access to this site.');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const listRef = firebaseAdmin.app().firestore().collection('orgs').doc(ctx.orgId).collection('lists').doc(listId);
|
|
111
|
+
const list = await listRef.get();
|
|
112
|
+
// A stale id must not CREATE a list: a campaign's audience would then
|
|
113
|
+
// read a list nobody set up.
|
|
114
|
+
if (!list.exists) throw new TransferEngineError('notFound', 404, 'This list no longer exists.');
|
|
115
|
+
return {
|
|
116
|
+
hostId,
|
|
117
|
+
listRef,
|
|
118
|
+
listName: String((_list_get = list.get('name')) != null ? _list_get : listId)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/*==========================================
|
|
122
|
+
* READING MEMBERS AND THEIR CONTACT RECORDS
|
|
123
|
+
*=========================================*/ /** Runs `fn` over `items`, `limit` at a time, answers in order. */ async function mapLimited(items, limit, fn) {
|
|
124
|
+
const out = new Array(items.length);
|
|
125
|
+
let next = 0;
|
|
126
|
+
const worker = async ()=>{
|
|
127
|
+
while(next < items.length){
|
|
128
|
+
const at = next;
|
|
129
|
+
next += 1;
|
|
130
|
+
out[at] = await fn(items[at]);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
await Promise.all(Array.from({
|
|
134
|
+
length: Math.min(limit, items.length)
|
|
135
|
+
}, worker));
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** A stored time as ISO text, or `null`. */ function isoOf(value) {
|
|
139
|
+
if (value === null || value === undefined) return null;
|
|
140
|
+
if (typeof value === 'number') return Number.isFinite(value) ? new Date(value).toISOString() : null;
|
|
141
|
+
const stamp = value;
|
|
142
|
+
if (typeof stamp.toDate === 'function') return stamp.toDate().toISOString();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* One member as the framework reads it: the membership by field id, and the
|
|
147
|
+
* consent as the site the list is worked as reads it.
|
|
148
|
+
*/ export function memberValues(id, data, group) {
|
|
149
|
+
var _ref, _text;
|
|
150
|
+
const record = readMarketingBasis(data, group);
|
|
151
|
+
const byHost = data[MARKETING_CONSENT_BY_HOST_FIELD];
|
|
152
|
+
const entry = (_ref = byHost == null ? void 0 : byHost[group.hostId]) != null ? _ref : data;
|
|
153
|
+
const text = (value)=>typeof value === 'string' && value ? value : null;
|
|
154
|
+
return {
|
|
155
|
+
[TRANSFER_ID_FIELD]: id,
|
|
156
|
+
email: text(data['email']),
|
|
157
|
+
name: text(data['name']),
|
|
158
|
+
addedAt: isoOf(data['addedAt']),
|
|
159
|
+
source: text(data['source']),
|
|
160
|
+
via: (_text = text(data['via'])) != null ? _text : 'manual',
|
|
161
|
+
consent: record.basis,
|
|
162
|
+
consentBasis: text(entry == null ? void 0 : entry[MARKETING_CONSENT_BASIS_FIELD]),
|
|
163
|
+
consentAt: isoOf(record.basisAtMs),
|
|
164
|
+
consentReason: text(entry == null ? void 0 : entry['marketingConsentReason'])
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The contact record's values for these addresses, as this site's holder
|
|
169
|
+
* keeps them, by `contact:<field>` — and only for addresses a record exists
|
|
170
|
+
* for, so "no record" and "a record with nothing in it" stay apart.
|
|
171
|
+
*/ export async function readContactValues(hostId, group, emails) {
|
|
172
|
+
const found = new Map();
|
|
173
|
+
const addresses = [
|
|
174
|
+
...new Set(emails.filter(Boolean))
|
|
175
|
+
];
|
|
176
|
+
if (!addresses.length) return found;
|
|
177
|
+
const contacts = await orgDataCollectionForHost(hostId, 'contacts');
|
|
178
|
+
const snapshots = await mapLimited(slices(addresses, IN_QUERY_MAX), READ_CONCURRENCY, (chunk)=>contacts.where('email', 'in', chunk).get());
|
|
179
|
+
for (const snapshot of snapshots){
|
|
180
|
+
for (const doc of snapshot.docs){
|
|
181
|
+
const email = normalizeContactEmail(doc.get('email'));
|
|
182
|
+
if (!email || found.has(email)) continue;
|
|
183
|
+
const facet = readContactFacet(doc.data(), group.groupId);
|
|
184
|
+
found.set(email, Object.fromEntries(LIST_MEMBER_CONTACT_FIELD_IDS.map((fieldId)=>{
|
|
185
|
+
const value = facet[contactFieldKey(fieldId)];
|
|
186
|
+
return [
|
|
187
|
+
fieldId,
|
|
188
|
+
typeof value === 'string' && value ? value : null
|
|
189
|
+
];
|
|
190
|
+
})));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return found;
|
|
194
|
+
}
|
|
195
|
+
/** The members holding these addresses or ids, by member id. */ async function readMembers(members, requests) {
|
|
196
|
+
const docs = new Map();
|
|
197
|
+
const lookup = new Map();
|
|
198
|
+
const note = (fieldId, value, id)=>{
|
|
199
|
+
var _lookup_get;
|
|
200
|
+
const key = matchLookupKey(fieldId, value);
|
|
201
|
+
const ids = (_lookup_get = lookup.get(key)) != null ? _lookup_get : [];
|
|
202
|
+
if (!ids.includes(id)) ids.push(id);
|
|
203
|
+
lookup.set(key, ids);
|
|
204
|
+
};
|
|
205
|
+
for (const request of requests){
|
|
206
|
+
if (request.fieldId === 'email') {
|
|
207
|
+
const snapshots = await mapLimited(slices(request.values, IN_QUERY_MAX), READ_CONCURRENCY, (chunk)=>members.where('email', 'in', chunk).get());
|
|
208
|
+
for (const snapshot of snapshots){
|
|
209
|
+
for (const doc of snapshot.docs){
|
|
210
|
+
const value = normalizeMatchValue('email', doc.get('email'));
|
|
211
|
+
if (!value) continue;
|
|
212
|
+
docs.set(doc.id, doc.data());
|
|
213
|
+
note('email', value, doc.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else if (request.fieldId === TRANSFER_ID_FIELD) {
|
|
217
|
+
const ids = request.values.filter((id)=>id && !id.includes('/'));
|
|
218
|
+
for (const chunk of slices(ids, 300)){
|
|
219
|
+
if (!chunk.length) continue;
|
|
220
|
+
const snapshots = await members.firestore.getAll(...chunk.map((id)=>members.doc(id)));
|
|
221
|
+
for (const snapshot of snapshots){
|
|
222
|
+
if (!snapshot.exists) continue;
|
|
223
|
+
docs.set(snapshot.id, snapshot.data());
|
|
224
|
+
note(TRANSFER_ID_FIELD, snapshot.id, snapshot.id);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
docs,
|
|
231
|
+
lookup
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/*==========================================
|
|
235
|
+
* THE PLAN
|
|
236
|
+
*=========================================*/ /** The warnings of several plans as one list, class by class. */ export function mergeTransferWarnings(lists) {
|
|
237
|
+
const byClass = new Map();
|
|
238
|
+
for (const list of lists){
|
|
239
|
+
for (const warning of list){
|
|
240
|
+
const held = byClass.get(warning.class);
|
|
241
|
+
byClass.set(warning.class, held ? _extends({}, held, {
|
|
242
|
+
count: held.count + warning.count,
|
|
243
|
+
rows: held.rows + warning.rows,
|
|
244
|
+
fieldIds: [
|
|
245
|
+
...new Set([
|
|
246
|
+
...held.fieldIds,
|
|
247
|
+
...warning.fieldIds
|
|
248
|
+
])
|
|
249
|
+
],
|
|
250
|
+
samples: [
|
|
251
|
+
...held.samples,
|
|
252
|
+
...warning.samples
|
|
253
|
+
].slice(0, Math.max(5, held.samples.length)),
|
|
254
|
+
requiresAcknowledgement: held.requiresAcknowledgement || warning.requiresAcknowledgement
|
|
255
|
+
}) : _extends({}, warning));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return TRANSFER_WARNING_CLASSES.filter((entry)=>byClass.has(entry)).map((entry)=>byClass.get(entry));
|
|
259
|
+
}
|
|
260
|
+
/** A row's values without the keys `keep` refuses. */ function pick(values, keep) {
|
|
261
|
+
return Object.fromEntries(Object.entries(values).filter(([fieldId])=>keep(fieldId)));
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The dry run (see the module note): the membership planned by the core,
|
|
265
|
+
* the contact columns planned against the contact records, the two joined
|
|
266
|
+
* row by row, and the screening added as one warning to acknowledge.
|
|
267
|
+
* Records who stated permission on the list's import ledger; writes no
|
|
268
|
+
* member and no contact.
|
|
269
|
+
*/ export async function planListMembers(ctx, input) {
|
|
270
|
+
var _ctx_headers, _ctx_jobId, _ctx_jobId1;
|
|
271
|
+
const list = await listTargetFor(ctx, {
|
|
272
|
+
write: true
|
|
273
|
+
});
|
|
274
|
+
const group = await consentGroupForSite(list.hostId);
|
|
275
|
+
const existingAnswer = readListExistingAnswer(ctx.extras);
|
|
276
|
+
const consentAnswer = readListConsentAnswer(ctx.extras);
|
|
277
|
+
const isContact = (fieldId)=>contactFieldKey(fieldId) !== null;
|
|
278
|
+
// A person is on a list once: "create a duplicate" is an update here.
|
|
279
|
+
const policy = _extends({}, input.policy, {
|
|
280
|
+
record: _extends({}, input.policy.record, {
|
|
281
|
+
onMatch: input.policy.record.onMatch === 'duplicate' ? 'update' : input.policy.record.onMatch
|
|
282
|
+
})
|
|
283
|
+
});
|
|
284
|
+
// 1. The membership, against the members the lookup found.
|
|
285
|
+
const membership = buildTransferPlan(_extends({}, input, {
|
|
286
|
+
policy,
|
|
287
|
+
fields: input.fields.filter((field)=>!isContact(field.id)),
|
|
288
|
+
rows: input.rows.map((row)=>_extends({}, row, {
|
|
289
|
+
values: pick(row.values, (fieldId)=>!isContact(fieldId))
|
|
290
|
+
}))
|
|
291
|
+
}));
|
|
292
|
+
const emailOf = new Map();
|
|
293
|
+
for (const row of input.rows){
|
|
294
|
+
const email = normalizeContactEmail(row.values['email']);
|
|
295
|
+
if (email) emailOf.set(row.index, email);
|
|
296
|
+
}
|
|
297
|
+
// 2. The contact columns, against the contact records, for every row that
|
|
298
|
+
// adds somebody — and for somebody already on the list only when the
|
|
299
|
+
// operator chose to update the people the workspace holds.
|
|
300
|
+
const carriesContacts = input.rows.some((row)=>Object.keys(row.values).some((fieldId)=>isContact(fieldId)));
|
|
301
|
+
const contactPlanned = new Map();
|
|
302
|
+
let contactWarnings = [];
|
|
303
|
+
if (carriesContacts) {
|
|
304
|
+
const eligible = membership.rows.filter((row)=>(row.verdict === 'create' || existingAnswer.updateContacts && (row.verdict === 'unchanged' || row.verdict === 'update') && row.recordId) && emailOf.has(row.index));
|
|
305
|
+
const records = await readContactValues(list.hostId, group, eligible.map((row)=>emailOf.get(row.index)));
|
|
306
|
+
const byIndex = new Map(input.rows.map((row)=>[
|
|
307
|
+
row.index,
|
|
308
|
+
row
|
|
309
|
+
]));
|
|
310
|
+
const contactRows = eligible.map((row)=>{
|
|
311
|
+
var _ref;
|
|
312
|
+
var _byIndex_get;
|
|
313
|
+
return {
|
|
314
|
+
index: row.index,
|
|
315
|
+
values: pick((_ref = (_byIndex_get = byIndex.get(row.index)) == null ? void 0 : _byIndex_get.values) != null ? _ref : {}, isContact)
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
const matches = eligible.map((row)=>{
|
|
319
|
+
const email = emailOf.get(row.index);
|
|
320
|
+
return records.has(email) ? {
|
|
321
|
+
kind: 'matched',
|
|
322
|
+
recordId: email,
|
|
323
|
+
via: {
|
|
324
|
+
fieldId: 'email',
|
|
325
|
+
value: email
|
|
326
|
+
}
|
|
327
|
+
} : {
|
|
328
|
+
kind: 'new'
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
const contactPolicy = _extends({}, policy, {
|
|
332
|
+
record: {
|
|
333
|
+
onMatch: 'update',
|
|
334
|
+
onNew: 'create',
|
|
335
|
+
onAmbiguous: 'skip'
|
|
336
|
+
},
|
|
337
|
+
rows: Object.fromEntries(Object.entries(policy.rows).map(([row, override])=>[
|
|
338
|
+
row,
|
|
339
|
+
override.fields ? {
|
|
340
|
+
fields: override.fields
|
|
341
|
+
} : {}
|
|
342
|
+
])),
|
|
343
|
+
locked: existingAnswer.updateContacts ? policy.locked : [
|
|
344
|
+
...policy.locked.filter((rule)=>!isContact(rule.fieldId)),
|
|
345
|
+
...LIST_MEMBER_CONTACT_FIELD_IDS.map((fieldId)=>({
|
|
346
|
+
fieldId,
|
|
347
|
+
forced: {
|
|
348
|
+
mode: 'keepExisting',
|
|
349
|
+
blank: 'leave'
|
|
350
|
+
},
|
|
351
|
+
reason: CONTACTS_KEPT_REASON
|
|
352
|
+
}))
|
|
353
|
+
]
|
|
354
|
+
});
|
|
355
|
+
const contacts = buildTransferPlan({
|
|
356
|
+
fields: input.fields.filter((field)=>isContact(field.id)),
|
|
357
|
+
rows: contactRows,
|
|
358
|
+
matches,
|
|
359
|
+
existing: records,
|
|
360
|
+
policy: contactPolicy
|
|
361
|
+
});
|
|
362
|
+
for (const row of contacts.rows)contactPlanned.set(row.index, row);
|
|
363
|
+
contactWarnings = contacts.warnings;
|
|
364
|
+
}
|
|
365
|
+
// 3. The two, joined: a new member carries both; somebody already on the
|
|
366
|
+
// list changes only when their contact record does.
|
|
367
|
+
const rows = membership.rows.map((row)=>{
|
|
368
|
+
const contact = contactPlanned.get(row.index);
|
|
369
|
+
if (!contact) return row;
|
|
370
|
+
const diff = contact.verdict === 'create' || contact.verdict === 'update' ? contact.diff : [];
|
|
371
|
+
const joined = _extends({}, row, {
|
|
372
|
+
heldBack: [
|
|
373
|
+
...row.heldBack,
|
|
374
|
+
...contact.heldBack
|
|
375
|
+
],
|
|
376
|
+
warnings: [
|
|
377
|
+
...new Set([
|
|
378
|
+
...row.warnings,
|
|
379
|
+
...contact.warnings
|
|
380
|
+
])
|
|
381
|
+
]
|
|
382
|
+
});
|
|
383
|
+
if (row.verdict === 'create') return _extends({}, joined, {
|
|
384
|
+
diff: [
|
|
385
|
+
...row.diff,
|
|
386
|
+
...diff
|
|
387
|
+
]
|
|
388
|
+
});
|
|
389
|
+
return diff.length ? _extends({}, joined, {
|
|
390
|
+
verdict: 'update',
|
|
391
|
+
diff
|
|
392
|
+
}) : joined;
|
|
393
|
+
});
|
|
394
|
+
// 4. The screening, over the people the import would add.
|
|
395
|
+
const adding = rows.filter((row)=>row.verdict === 'create' && emailOf.has(row.index)).map((row)=>({
|
|
396
|
+
index: row.index,
|
|
397
|
+
email: emailOf.get(row.index)
|
|
398
|
+
}));
|
|
399
|
+
const roles = adding.filter((entry)=>isRoleAccount(entry.email));
|
|
400
|
+
const tells = purchaseTellColumns((_ctx_headers = ctx.headers) != null ? _ctx_headers : []);
|
|
401
|
+
const sampled = adding.slice(0, LIST_MEMBER_BATCH_MAX).map((entry)=>entry.email);
|
|
402
|
+
const resolution = sampled.length ? await resolveAddresses({
|
|
403
|
+
hostId: list.hostId,
|
|
404
|
+
inputs: sampled
|
|
405
|
+
}) : {
|
|
406
|
+
optedIn: 0,
|
|
407
|
+
needAttestation: 0,
|
|
408
|
+
refused: 0
|
|
409
|
+
};
|
|
410
|
+
const attested = (consentAnswer == null ? void 0 : consentAnswer.attest) === true;
|
|
411
|
+
const declaresBasis = input.rows.some((row)=>{
|
|
412
|
+
var _row_values_declaredSource, _row_values_declaredAt;
|
|
413
|
+
return !!String((_row_values_declaredSource = row.values['declaredSource']) != null ? _row_values_declaredSource : '').trim() || !!String((_row_values_declaredAt = row.values['declaredAt']) != null ? _row_values_declaredAt : '').trim();
|
|
414
|
+
});
|
|
415
|
+
const report = {
|
|
416
|
+
roleAccounts: roles.length,
|
|
417
|
+
roleAccountSamples: roles.slice(0, ROLE_ACCOUNT_SAMPLE_MAX).map((entry)=>entry.email),
|
|
418
|
+
purchaseTellColumns: tells,
|
|
419
|
+
declaresBasis,
|
|
420
|
+
sample: {
|
|
421
|
+
size: sampled.length,
|
|
422
|
+
of: adding.length,
|
|
423
|
+
optedIn: resolution.optedIn,
|
|
424
|
+
needAttestation: resolution.needAttestation,
|
|
425
|
+
refused: resolution.refused
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const screening = screeningWarning(report, roles, attested);
|
|
429
|
+
const warnings = mergeTransferWarnings([
|
|
430
|
+
membership.warnings,
|
|
431
|
+
contactWarnings,
|
|
432
|
+
screening ? [
|
|
433
|
+
screening
|
|
434
|
+
] : []
|
|
435
|
+
]);
|
|
436
|
+
await list.listRef.collection(LIST_IMPORTS_SUBCOLLECTION).doc(String((_ctx_jobId = ctx.jobId) != null ? _ctx_jobId : 'unknown')).set({
|
|
437
|
+
jobId: (_ctx_jobId1 = ctx.jobId) != null ? _ctx_jobId1 : null,
|
|
438
|
+
listName: list.listName,
|
|
439
|
+
hostId: list.hostId,
|
|
440
|
+
status: 'planned',
|
|
441
|
+
total: adding.length,
|
|
442
|
+
screening: report,
|
|
443
|
+
updateContacts: existingAnswer.updateContacts,
|
|
444
|
+
/*
|
|
445
|
+
* WHO stated permission, stored beside WHETHER — from the session
|
|
446
|
+
* that made this dry run, never from the browser's answer. Every
|
|
447
|
+
* write reads the attester from here.
|
|
448
|
+
*/ attested,
|
|
449
|
+
attestedByUid: attested ? ctx.actorUid : null,
|
|
450
|
+
attestedAtMs: attested ? Date.now() : null,
|
|
451
|
+
plannedByUid: ctx.actorUid,
|
|
452
|
+
updatedAt: FieldValue.serverTimestamp(),
|
|
453
|
+
// Re-stamped by every dry run, so the latest one sets the clock.
|
|
454
|
+
expiresAt: listImportLedgerExpiry(Date.now())
|
|
455
|
+
}, {
|
|
456
|
+
merge: true
|
|
457
|
+
});
|
|
458
|
+
const summary = {
|
|
459
|
+
create: 0,
|
|
460
|
+
update: 0,
|
|
461
|
+
unchanged: 0,
|
|
462
|
+
skip: 0,
|
|
463
|
+
fail: 0,
|
|
464
|
+
total: rows.length
|
|
465
|
+
};
|
|
466
|
+
for (const row of rows)summary[row.verdict] += 1;
|
|
467
|
+
return {
|
|
468
|
+
rows,
|
|
469
|
+
summary,
|
|
470
|
+
warnings,
|
|
471
|
+
acknowledgementsRequired: warnings.filter((entry)=>entry.requiresAcknowledgement).map((entry)=>entry.class)
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* The screening as one warning, or `null` when it found nothing worth a
|
|
476
|
+
* word: the purchase tells first, then what the consent sample says will
|
|
477
|
+
* happen, then the role accounts.
|
|
478
|
+
*/ export function screeningWarning(report, roles, attested) {
|
|
479
|
+
const samples = report.purchaseTellColumns.map((column)=>({
|
|
480
|
+
row: TRANSFER_FILE_SAMPLE_ROW,
|
|
481
|
+
value: column,
|
|
482
|
+
detail: 'A column named like a bought, rented or appended list. Those lists are not allowed: importing one puts every site sending through this domain at risk. If the name is a coincidence, carry on.'
|
|
483
|
+
}));
|
|
484
|
+
const { sample } = report;
|
|
485
|
+
const unlisted = !attested && sample.needAttestation > 0;
|
|
486
|
+
if (sample.refused > 0 || unlisted) {
|
|
487
|
+
samples.push({
|
|
488
|
+
row: TRANSFER_FILE_SAMPLE_ROW,
|
|
489
|
+
detail: `${sample.size < sample.of ? `Of the first ${sample.size} of ${sample.of} new addresses` : `Of the ${sample.size} new addresses`}, ` + `${sample.optedIn} have an opt-in on record, ${sample.needAttestation} have none, and ${sample.refused} can never be added ` + '(they unsubscribed, bounced, complained or said no). ' + (unlisted ? 'You have not stated you have permission, so addresses with no opt-in on record will not be added. ' : '') + 'Every address is checked again as it is added.'
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
for (const role of roles.slice(0, 5)){
|
|
493
|
+
samples.push({
|
|
494
|
+
row: role.index,
|
|
495
|
+
fieldId: 'email',
|
|
496
|
+
value: role.email,
|
|
497
|
+
detail: 'A shared mailbox — common in lists collected rather than signed up to. It is added if you go ahead.'
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const count = report.purchaseTellColumns.length + roles.length + (sample.refused > 0 || unlisted ? 1 : 0);
|
|
501
|
+
if (!count) return null;
|
|
502
|
+
return {
|
|
503
|
+
class: 'screening',
|
|
504
|
+
count,
|
|
505
|
+
rows: roles.length,
|
|
506
|
+
fieldIds: roles.length ? [
|
|
507
|
+
'email'
|
|
508
|
+
] : [],
|
|
509
|
+
samples,
|
|
510
|
+
requiresAcknowledgement: true
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/*==========================================
|
|
514
|
+
* APPLY
|
|
515
|
+
*=========================================*/ /** A field's planned value in a row's diff, or `undefined` when it does not change. */ function after(row, fieldId) {
|
|
516
|
+
var _row_diff_find;
|
|
517
|
+
return (_row_diff_find = row.diff.find((change)=>change.fieldId === fieldId)) == null ? void 0 : _row_diff_find.after;
|
|
518
|
+
}
|
|
519
|
+
/** The contact columns a row changes, by the record's own field names, blanks left out. */ function contactProfile(row) {
|
|
520
|
+
const profile = {};
|
|
521
|
+
for (const change of row.diff){
|
|
522
|
+
const key = contactFieldKey(change.fieldId);
|
|
523
|
+
if (key && typeof change.after === 'string' && change.after.trim()) profile[key] = change.after.trim();
|
|
524
|
+
}
|
|
525
|
+
return profile;
|
|
526
|
+
}
|
|
527
|
+
/** Hands a row's contact columns to the record system; `null` when it said why not. */ async function captureContact(ctx, list, email, name, profile) {
|
|
528
|
+
if (!Object.keys(profile).length) return null;
|
|
529
|
+
const captured = await capturePluginContact(_extends({
|
|
530
|
+
orgId: ctx.orgId,
|
|
531
|
+
hostId: list.hostId
|
|
532
|
+
}, ctx.actorUid ? {
|
|
533
|
+
actor: {
|
|
534
|
+
kind: 'member',
|
|
535
|
+
uid: ctx.actorUid
|
|
536
|
+
}
|
|
537
|
+
} : {}, {
|
|
538
|
+
identity: _extends({
|
|
539
|
+
email
|
|
540
|
+
}, name ? {
|
|
541
|
+
name
|
|
542
|
+
} : {}),
|
|
543
|
+
interaction: {
|
|
544
|
+
source: 'import',
|
|
545
|
+
summary: `Imported onto the list “${list.listName}”`
|
|
546
|
+
},
|
|
547
|
+
surface: 'touch',
|
|
548
|
+
profile
|
|
549
|
+
}));
|
|
550
|
+
return captured && captured.ok === false ? captured.error : null;
|
|
551
|
+
}
|
|
552
|
+
/** One chunk's writes (see the module note). */ export async function applyListMembers(ctx, chunk, writer) {
|
|
553
|
+
var _ctx_jobId, _ledger_get;
|
|
554
|
+
var _readListConsentAnswer;
|
|
555
|
+
const list = await listTargetFor(ctx, {
|
|
556
|
+
write: true
|
|
557
|
+
});
|
|
558
|
+
const results = [];
|
|
559
|
+
const undo = [];
|
|
560
|
+
/*
|
|
561
|
+
* The ATTESTER's account, read from the ledger the dry run wrote — not
|
|
562
|
+
* the caller's, and never the browser's. A colleague who resumes somebody
|
|
563
|
+
* else's import has asserted nothing, and a consent record naming them
|
|
564
|
+
* would be a claim nobody made. Both halves must agree: the plan this
|
|
565
|
+
* apply runs was made with the statement, and the ledger says who made it.
|
|
566
|
+
*/ const ledger = await list.listRef.collection(LIST_IMPORTS_SUBCOLLECTION).doc(String((_ctx_jobId = ctx.jobId) != null ? _ctx_jobId : 'unknown')).get();
|
|
567
|
+
const attestingUid = String((_ledger_get = ledger.get('attestedByUid')) != null ? _ledger_get : '');
|
|
568
|
+
const attested = ((_readListConsentAnswer = readListConsentAnswer(ctx.extras)) == null ? void 0 : _readListConsentAnswer.attest) === true && ledger.get('attested') === true && !!attestingUid;
|
|
569
|
+
const pending = [];
|
|
570
|
+
for (const row of chunk.rows){
|
|
571
|
+
const done = await writer.alreadyApplied(row.index);
|
|
572
|
+
if (done) results.push(done);
|
|
573
|
+
else pending.push(row);
|
|
574
|
+
}
|
|
575
|
+
for (const batch of slices(pending, LIST_MEMBER_BATCH_MAX)){
|
|
576
|
+
if (writer.timeLeftMs() < APPLY_RESERVE_MS) break;
|
|
577
|
+
const creates = batch.filter((row)=>row.verdict === 'create');
|
|
578
|
+
const emails = creates.map((row)=>{
|
|
579
|
+
var _normalizeContactEmail;
|
|
580
|
+
return (_normalizeContactEmail = normalizeContactEmail(after(row, 'email'))) != null ? _normalizeContactEmail : '';
|
|
581
|
+
});
|
|
582
|
+
const resolution = await resolveAddresses({
|
|
583
|
+
hostId: list.hostId,
|
|
584
|
+
inputs: emails.filter(Boolean)
|
|
585
|
+
});
|
|
586
|
+
const verdicts = new Map(resolution.verdicts.filter((verdict)=>verdict.email).map((verdict)=>[
|
|
587
|
+
verdict.email,
|
|
588
|
+
verdict
|
|
589
|
+
]));
|
|
590
|
+
const nowMs = Date.now();
|
|
591
|
+
for (const row of batch){
|
|
592
|
+
if (writer.timeLeftMs() < APPLY_RESERVE_MS) break;
|
|
593
|
+
const result = row.verdict === 'create' ? await addMember(ctx, list, row, {
|
|
594
|
+
resolution,
|
|
595
|
+
verdicts,
|
|
596
|
+
attested,
|
|
597
|
+
attestingUid,
|
|
598
|
+
nowMs
|
|
599
|
+
}) : await updateMemberContact(ctx, list, row);
|
|
600
|
+
await writer.markApplied(result.result, result.undo);
|
|
601
|
+
results.push(result.result);
|
|
602
|
+
if (result.undo) undo.push(result.undo);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
results,
|
|
607
|
+
undo
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
/** A refusal at the write, as a row result. */ function refused(row, reason) {
|
|
611
|
+
return {
|
|
612
|
+
row: row.index,
|
|
613
|
+
outcome: 'failed',
|
|
614
|
+
reason,
|
|
615
|
+
message: ASSIGNMENT_REFUSAL_MESSAGES[reason]
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/** One new member, through the same gate and writer a typed address goes through. */ async function addMember(ctx, list, row, batch) {
|
|
619
|
+
var _ref, _batch_resolution_stored_get, _profile_firstName, _profile_lastName, _batch_resolution_grants_get;
|
|
620
|
+
const email = normalizeContactEmail(after(row, 'email'));
|
|
621
|
+
if (!email) return {
|
|
622
|
+
result: refused(row, 'unroutable-address')
|
|
623
|
+
};
|
|
624
|
+
const verdict = batch.verdicts.get(email);
|
|
625
|
+
if (!verdict || verdict.refusal) return {
|
|
626
|
+
result: refused(row, (_ref = verdict == null ? void 0 : verdict.refusal) != null ? _ref : 'unroutable-address')
|
|
627
|
+
};
|
|
628
|
+
const decision = assignmentBasis({
|
|
629
|
+
stored: (_batch_resolution_stored_get = batch.resolution.stored.get(email)) != null ? _batch_resolution_stored_get : readMarketingBasis(null, batch.resolution.group),
|
|
630
|
+
attested: batch.attested,
|
|
631
|
+
actingUid: batch.attestingUid,
|
|
632
|
+
nowMs: batch.nowMs
|
|
633
|
+
});
|
|
634
|
+
if ('refusal' in decision) return {
|
|
635
|
+
result: refused(row, decision.refusal)
|
|
636
|
+
};
|
|
637
|
+
const text = (fieldId)=>{
|
|
638
|
+
const value = after(row, fieldId);
|
|
639
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
640
|
+
};
|
|
641
|
+
const profile = contactProfile(row);
|
|
642
|
+
// The list shows a name: the file's, else the one its first and last names make.
|
|
643
|
+
const name = text('name') || composeContactName((_profile_firstName = profile['firstName']) != null ? _profile_firstName : '', (_profile_lastName = profile['lastName']) != null ? _profile_lastName : '');
|
|
644
|
+
const enrollment = await enrollListMember(_extends({
|
|
645
|
+
listRef: list.listRef,
|
|
646
|
+
group: batch.resolution.group,
|
|
647
|
+
email
|
|
648
|
+
}, name ? {
|
|
649
|
+
name
|
|
650
|
+
} : {}, {
|
|
651
|
+
source: CONSOLE_IMPORT_SOURCE,
|
|
652
|
+
// Never `'rule'`: the dynamic-list materializer reconciles its own rows
|
|
653
|
+
// away when somebody stops matching, and a file a merchant uploaded is
|
|
654
|
+
// not a rule match that can lapse.
|
|
655
|
+
via: 'manual'
|
|
656
|
+
}, decision.basis === 'contact-opt-in' ? {
|
|
657
|
+
grantEntries: (_batch_resolution_grants_get = batch.resolution.grants.get(email)) != null ? _batch_resolution_grants_get : {}
|
|
658
|
+
} : {}, {
|
|
659
|
+
consent: _extends({}, decision, decision.basis === 'operator-attested' ? {
|
|
660
|
+
reason: importedBasisReason({
|
|
661
|
+
declaredSource: text('declaredSource'),
|
|
662
|
+
declaredAt: text('declaredAt')
|
|
663
|
+
})
|
|
664
|
+
} : {})
|
|
665
|
+
}));
|
|
666
|
+
if (enrollment.enrolled === false) {
|
|
667
|
+
return {
|
|
668
|
+
result: refused(row, enrollment.refusal === 'declined' ? 'declined' : 'unroutable-address')
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const contactError = await captureContact(ctx, list, email, name || undefined, profile);
|
|
672
|
+
return _extends({
|
|
673
|
+
result: _extends({
|
|
674
|
+
row: row.index,
|
|
675
|
+
outcome: enrollment.created ? 'created' : 'updated',
|
|
676
|
+
recordId: enrollment.memberId
|
|
677
|
+
}, contactError ? {
|
|
678
|
+
message: `Added to the list. ${contactError}`
|
|
679
|
+
} : {})
|
|
680
|
+
}, enrollment.created ? {
|
|
681
|
+
undo: {
|
|
682
|
+
row: row.index,
|
|
683
|
+
recordId: enrollment.memberId,
|
|
684
|
+
action: 'created',
|
|
685
|
+
written: _extends({
|
|
686
|
+
email
|
|
687
|
+
}, name ? {
|
|
688
|
+
name
|
|
689
|
+
} : {}, {
|
|
690
|
+
consent: 'granted'
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
} : {});
|
|
694
|
+
}
|
|
695
|
+
/** Somebody already on the list whose contact record the operator chose to update. */ async function updateMemberContact(ctx, list, row) {
|
|
696
|
+
const member = row.recordId ? await list.listRef.collection('members').doc(row.recordId).get() : null;
|
|
697
|
+
const email = normalizeContactEmail(member == null ? void 0 : member.get('email'));
|
|
698
|
+
if (!(member == null ? void 0 : member.exists) || !email) {
|
|
699
|
+
return {
|
|
700
|
+
result: {
|
|
701
|
+
row: row.index,
|
|
702
|
+
outcome: 'failed',
|
|
703
|
+
reason: 'matchedRecordMissing'
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const contactError = await captureContact(ctx, list, email, undefined, contactProfile(row));
|
|
708
|
+
return {
|
|
709
|
+
result: contactError ? {
|
|
710
|
+
row: row.index,
|
|
711
|
+
outcome: 'failed',
|
|
712
|
+
recordId: member.id,
|
|
713
|
+
message: contactError
|
|
714
|
+
} : {
|
|
715
|
+
row: row.index,
|
|
716
|
+
outcome: 'updated',
|
|
717
|
+
recordId: member.id
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/*==========================================
|
|
722
|
+
* UNDO
|
|
723
|
+
*=========================================*/ /**
|
|
724
|
+
* Takes the import's new members off the list again. A membership that now
|
|
725
|
+
* records a REFUSAL is never deleted, whatever the person decided: deleting
|
|
726
|
+
* it would delete the record that the person said no.
|
|
727
|
+
*/ export async function revertListMembers(ctx, snapshot, decisions) {
|
|
728
|
+
const list = await listTargetFor(ctx, {
|
|
729
|
+
write: true
|
|
730
|
+
});
|
|
731
|
+
const group = await consentGroupForSite(list.hostId);
|
|
732
|
+
const members = list.listRef.collection('members');
|
|
733
|
+
const done = [];
|
|
734
|
+
const conflicts = [];
|
|
735
|
+
for (const entry of snapshot.entries){
|
|
736
|
+
const doc = await members.doc(entry.recordId).get();
|
|
737
|
+
const current = doc.exists ? memberValues(doc.id, doc.data(), group) : null;
|
|
738
|
+
const step = planTransferUndo(entry, current);
|
|
739
|
+
if ((current == null ? void 0 : current['consent']) === 'declined') {
|
|
740
|
+
done.push({
|
|
741
|
+
action: 'nothing',
|
|
742
|
+
recordId: entry.recordId,
|
|
743
|
+
why: 'alreadyReverted'
|
|
744
|
+
});
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (step.action === 'delete' || step.action === 'conflict' && (decisions == null ? void 0 : decisions[entry.recordId]) === 'revert') {
|
|
748
|
+
await doc.ref.delete();
|
|
749
|
+
done.push({
|
|
750
|
+
action: 'delete',
|
|
751
|
+
recordId: entry.recordId
|
|
752
|
+
});
|
|
753
|
+
} else if (step.action === 'conflict') {
|
|
754
|
+
conflicts.push(step);
|
|
755
|
+
} else {
|
|
756
|
+
done.push(step);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
done,
|
|
761
|
+
conflicts
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/*==========================================
|
|
765
|
+
* EXPORT
|
|
766
|
+
*=========================================*/ /** The member-table predicates an export may carry: the table's own, nothing else. */ const FILTERABLE = {
|
|
767
|
+
email: [
|
|
768
|
+
'=='
|
|
769
|
+
],
|
|
770
|
+
via: [
|
|
771
|
+
'=='
|
|
772
|
+
],
|
|
773
|
+
searchTokens: [
|
|
774
|
+
'array-contains'
|
|
775
|
+
]
|
|
776
|
+
};
|
|
777
|
+
/**
|
|
778
|
+
* The list table's filter as the export reads it — `{ filters }`, each a
|
|
779
|
+
* predicate the table put on its own query — checked clause by clause:
|
|
780
|
+
* only the table's fields and operators, so a request cannot ask the Admin
|
|
781
|
+
* SDK a question the table never could.
|
|
782
|
+
*/ export function readMemberFilter(filter) {
|
|
783
|
+
const clauses = Array.isArray(filter == null ? void 0 : filter['filters']) ? filter == null ? void 0 : filter['filters'] : [];
|
|
784
|
+
return clauses.map((clause)=>{
|
|
785
|
+
var _FILTERABLE_path;
|
|
786
|
+
const { path, op, value } = clause != null ? clause : {};
|
|
787
|
+
if (typeof path !== 'string' || typeof op !== 'string' || typeof value !== 'string' || !((_FILTERABLE_path = FILTERABLE[path]) == null ? void 0 : _FILTERABLE_path.includes(op))) {
|
|
788
|
+
throw new TransferEngineError('invalid', 400, 'The filter could not be read.');
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
path,
|
|
792
|
+
op: op,
|
|
793
|
+
value
|
|
794
|
+
};
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
/** The members query for an export's filter. */ function memberQuery(members, options) {
|
|
798
|
+
let query = members;
|
|
799
|
+
for (const clause of readMemberFilter(options == null ? void 0 : options.filter))query = query.where(clause.path, clause.op, clause.value);
|
|
800
|
+
return query;
|
|
801
|
+
}
|
|
802
|
+
/** One page of members, holding only the chosen fields. */ export async function readListMemberPage(ctx, cursor, fieldIds, options) {
|
|
803
|
+
var _ref;
|
|
804
|
+
// A collaborator scoped to some sites reads no member: the rules give the
|
|
805
|
+
// organization's lists to org-wide members only.
|
|
806
|
+
if (options == null ? void 0 : options.scopeTokens) return {
|
|
807
|
+
rows: [],
|
|
808
|
+
next: null
|
|
809
|
+
};
|
|
810
|
+
const list = await listTargetFor(ctx, {
|
|
811
|
+
write: false
|
|
812
|
+
});
|
|
813
|
+
const group = await consentGroupForSite(list.hostId);
|
|
814
|
+
const members = list.listRef.collection('members');
|
|
815
|
+
const size = Math.min(Math.max((_ref = options == null ? void 0 : options.pageSize) != null ? _ref : 500, 1), 500);
|
|
816
|
+
let docs;
|
|
817
|
+
let next;
|
|
818
|
+
if (options == null ? void 0 : options.ids) {
|
|
819
|
+
const start = cursor ? Number(cursor) : 0;
|
|
820
|
+
const ids = options.ids.slice(start, start + size).filter((id)=>id && !id.includes('/'));
|
|
821
|
+
const snapshots = ids.length ? await members.firestore.getAll(...ids.map((id)=>members.doc(id))) : [];
|
|
822
|
+
docs = snapshots.filter((one)=>one.exists).map((one)=>({
|
|
823
|
+
id: one.id,
|
|
824
|
+
data: one.data()
|
|
825
|
+
}));
|
|
826
|
+
next = start + size < options.ids.length ? String(start + size) : null;
|
|
827
|
+
} else {
|
|
828
|
+
var _ref1;
|
|
829
|
+
var _snapshot_docs_;
|
|
830
|
+
let query = memberQuery(members, options).orderBy('__name__').limit(size);
|
|
831
|
+
if (cursor) query = query.startAfter(cursor);
|
|
832
|
+
const snapshot = await query.get();
|
|
833
|
+
docs = snapshot.docs.map((one)=>({
|
|
834
|
+
id: one.id,
|
|
835
|
+
data: one.data()
|
|
836
|
+
}));
|
|
837
|
+
next = snapshot.docs.length === size ? (_ref1 = (_snapshot_docs_ = snapshot.docs[snapshot.docs.length - 1]) == null ? void 0 : _snapshot_docs_.id) != null ? _ref1 : null : null;
|
|
838
|
+
}
|
|
839
|
+
const wantsContacts = fieldIds.some((fieldId)=>contactFieldKey(fieldId) !== null);
|
|
840
|
+
const contacts = wantsContacts ? await readContactValues(list.hostId, group, docs.map((doc)=>{
|
|
841
|
+
var _normalizeContactEmail;
|
|
842
|
+
return (_normalizeContactEmail = normalizeContactEmail(doc.data['email'])) != null ? _normalizeContactEmail : '';
|
|
843
|
+
})) : new Map();
|
|
844
|
+
const rows = docs.map((doc)=>{
|
|
845
|
+
var _contacts_get, _normalizeContactEmail;
|
|
846
|
+
const values = _extends({}, memberValues(doc.id, doc.data, group), (_contacts_get = contacts.get((_normalizeContactEmail = normalizeContactEmail(doc.data['email'])) != null ? _normalizeContactEmail : '')) != null ? _contacts_get : {});
|
|
847
|
+
return Object.fromEntries(fieldIds.map((fieldId)=>{
|
|
848
|
+
var _values_fieldId;
|
|
849
|
+
return [
|
|
850
|
+
fieldId,
|
|
851
|
+
(_values_fieldId = values[fieldId]) != null ? _values_fieldId : null
|
|
852
|
+
];
|
|
853
|
+
}));
|
|
854
|
+
});
|
|
855
|
+
return {
|
|
856
|
+
rows,
|
|
857
|
+
next
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/** How many members an export reads. */ export async function countListMembers(ctx, options) {
|
|
861
|
+
var _counted_data_count;
|
|
862
|
+
if (options.scopeTokens) return 0;
|
|
863
|
+
const list = await listTargetFor(ctx, {
|
|
864
|
+
write: false
|
|
865
|
+
});
|
|
866
|
+
if (options.ids) return countExisting(list.listRef.collection('members'), options.ids);
|
|
867
|
+
const counted = await memberQuery(list.listRef.collection('members'), options).count().get();
|
|
868
|
+
return Number((_counted_data_count = counted.data().count) != null ? _counted_data_count : 0);
|
|
869
|
+
}
|
|
870
|
+
/*==========================================
|
|
871
|
+
* LOOKUP, AND THE WHOLE RESOURCE
|
|
872
|
+
*=========================================*/ /**
|
|
873
|
+
* The members holding each requested address or id, and what each holds now
|
|
874
|
+
* — the membership, and their contact record's columns, so the conflicts
|
|
875
|
+
* step can show what an update would replace.
|
|
876
|
+
*/ export async function lookupListMembers(ctx, requests) {
|
|
877
|
+
const list = await listTargetFor(ctx, {
|
|
878
|
+
write: true
|
|
879
|
+
});
|
|
880
|
+
const group = await consentGroupForSite(list.hostId);
|
|
881
|
+
const { docs, lookup } = await readMembers(list.listRef.collection('members'), requests);
|
|
882
|
+
const contacts = await readContactValues(list.hostId, group, [
|
|
883
|
+
...docs.values()
|
|
884
|
+
].map((data)=>{
|
|
885
|
+
var _normalizeContactEmail;
|
|
886
|
+
return (_normalizeContactEmail = normalizeContactEmail(data['email'])) != null ? _normalizeContactEmail : '';
|
|
887
|
+
}));
|
|
888
|
+
const records = new Map();
|
|
889
|
+
for (const [id, data] of docs){
|
|
890
|
+
var _contacts_get, _normalizeContactEmail;
|
|
891
|
+
records.set(id, _extends({}, memberValues(id, data, group), (_contacts_get = contacts.get((_normalizeContactEmail = normalizeContactEmail(data['email'])) != null ? _normalizeContactEmail : '')) != null ? _contacts_get : {}));
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
lookup,
|
|
895
|
+
records
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/** `email.list-members`, as the framework asks it. */ export const listMembersTransferResource = {
|
|
899
|
+
fields: ()=>listMemberCatalog(),
|
|
900
|
+
matchKeys: LIST_MEMBER_MATCH_KEYS,
|
|
901
|
+
aliases: LIST_MEMBER_ALIASES,
|
|
902
|
+
lockedRules: ()=>LIST_MEMBER_LOCKED_RULES,
|
|
903
|
+
count: countListMembers,
|
|
904
|
+
readPage: readListMemberPage,
|
|
905
|
+
lookup: lookupListMembers,
|
|
906
|
+
plan: planListMembers,
|
|
907
|
+
apply: applyListMembers,
|
|
908
|
+
revert: revertListMembers
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
//# sourceMappingURL=list-members.server.js.map
|