@aglyn/plugins-email 1.0.0-beta.221 → 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.
Files changed (82) hide show
  1. package/README.md +5 -4
  2. package/package.json +16 -17
  3. package/src/lib/components/email-template-detail.d.ts +5 -13
  4. package/src/lib/components/email-template-detail.js +29 -426
  5. package/src/lib/components/email-template-detail.js.map +1 -1
  6. package/src/lib/components/email-zones.d.ts +15 -0
  7. package/src/lib/components/email-zones.js +1 -0
  8. package/src/lib/components/email-zones.js.map +1 -1
  9. package/src/lib/components/list-detail-card.d.ts +0 -36
  10. package/src/lib/components/list-detail-card.js +125 -84
  11. package/src/lib/components/list-detail-card.js.map +1 -1
  12. package/src/lib/components/list-members-panel.d.ts +17 -0
  13. package/src/lib/components/list-members-panel.js +14 -0
  14. package/src/lib/components/list-members-panel.js.map +1 -1
  15. package/src/lib/components/site-package-email-preview.d.ts +13 -0
  16. package/src/lib/components/site-package-email-preview.js +47 -0
  17. package/src/lib/components/site-package-email-preview.js.map +1 -0
  18. package/src/lib/components/suppressions-card.js +75 -6
  19. package/src/lib/components/suppressions-card.js.map +1 -1
  20. package/src/lib/components/use-org-email-topics.js +2 -9
  21. package/src/lib/components/use-org-email-topics.js.map +1 -1
  22. package/src/lib/constants/site-package.d.ts +21 -0
  23. package/src/lib/constants/site-package.js +21 -0
  24. package/src/lib/constants/site-package.js.map +1 -0
  25. package/src/lib/declarations.console-server.d.ts +12 -0
  26. package/src/lib/declarations.console-server.js +73 -0
  27. package/src/lib/declarations.console-server.js.map +1 -1
  28. package/src/lib/list-import.d.ts +64 -75
  29. package/src/lib/list-import.js +43 -158
  30. package/src/lib/list-import.js.map +1 -1
  31. package/src/lib/model/email-topic-catalog.d.ts +24 -0
  32. package/src/lib/model/email-topic-catalog.js +19 -0
  33. package/src/lib/model/email-topic-catalog.js.map +1 -1
  34. package/src/lib/model/index.d.ts +0 -25
  35. package/src/lib/model/index.js +0 -27
  36. package/src/lib/model/index.js.map +1 -1
  37. package/src/lib/plugin.d.ts +8 -0
  38. package/src/lib/plugin.js +76 -2
  39. package/src/lib/plugin.js.map +1 -1
  40. package/src/lib/server/person-eraser.d.ts +39 -0
  41. package/src/lib/server/person-eraser.js +44 -0
  42. package/src/lib/server/person-eraser.js.map +1 -0
  43. package/src/lib/server-console.js +9 -9
  44. package/src/lib/server-console.js.map +1 -1
  45. package/src/lib/server-list-gate.d.ts +7 -0
  46. package/src/lib/server-list-gate.js +10 -3
  47. package/src/lib/server-list-gate.js.map +1 -1
  48. package/src/lib/server-list-import.d.ts +20 -177
  49. package/src/lib/server-list-import.js +34 -594
  50. package/src/lib/server-list-import.js.map +1 -1
  51. package/src/lib/server.js +16 -29
  52. package/src/lib/server.js.map +1 -1
  53. package/src/lib/testing/stand-in-send-tally.d.ts +29 -0
  54. package/src/lib/testing/stand-in-send-tally.js +52 -0
  55. package/src/lib/testing/stand-in-send-tally.js.map +1 -0
  56. package/src/lib/transfer/email-transfer-catalog.d.ts +151 -0
  57. package/src/lib/transfer/email-transfer-catalog.js +536 -0
  58. package/src/lib/transfer/email-transfer-catalog.js.map +1 -0
  59. package/src/lib/transfer/list-import-steps.d.ts +44 -0
  60. package/src/lib/transfer/list-import-steps.js +201 -0
  61. package/src/lib/transfer/list-import-steps.js.map +1 -0
  62. package/src/lib/transfer/list-members.server.d.ts +165 -0
  63. package/src/lib/transfer/list-members.server.js +911 -0
  64. package/src/lib/transfer/list-members.server.js.map +1 -0
  65. package/src/lib/transfer/suppressions.server.d.ts +69 -0
  66. package/src/lib/transfer/suppressions.server.js +282 -0
  67. package/src/lib/transfer/suppressions.server.js.map +1 -0
  68. package/src/lib/transfer/topics-package.d.ts +56 -0
  69. package/src/lib/transfer/topics-package.js +87 -0
  70. package/src/lib/transfer/topics-package.js.map +1 -0
  71. package/src/lib/transfer/topics-package.server.d.ts +33 -0
  72. package/src/lib/transfer/topics-package.server.js +171 -0
  73. package/src/lib/transfer/topics-package.server.js.map +1 -0
  74. package/src/lib/transfer/transfer-reads.server.d.ts +25 -0
  75. package/src/lib/transfer/transfer-reads.server.js +34 -0
  76. package/src/lib/transfer/transfer-reads.server.js.map +1 -0
  77. package/src/lib/components/list-import-drawer.d.ts +0 -22
  78. package/src/lib/components/list-import-drawer.js +0 -662
  79. package/src/lib/components/list-import-drawer.js.map +0 -1
  80. package/src/lib/model/template-report.d.ts +0 -158
  81. package/src/lib/model/template-report.js +0 -249
  82. 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