@aglyn/plugins-crm 1.0.0-beta.195 → 1.0.0-beta.197
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contacts-section.js +18 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/leads-section.js +2 -2
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +6 -0
- package/src/lib/components/new-contact-drawer.js +3 -2
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/constants/contact-filters.js +6 -0
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/declarations.server.js +21 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +10 -0
- package/src/lib/hooks/use-crm-scope.js +32 -4
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/consent-group-summary.d.ts +25 -0
- package/src/lib/model/consent-group-summary.js +32 -0
- package/src/lib/model/consent-group-summary.js.map +1 -0
- package/src/lib/server/capture-contact.js +16 -2
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/consent-group-participant.d.ts +81 -0
- package/src/lib/server/consent-group-participant.js +605 -0
- package/src/lib/server/consent-group-participant.js.map +1 -0
- package/src/lib/server.d.ts +2 -1
- package/src/lib/server.js +10 -2
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,605 @@
|
|
|
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
|
+
* THE CRM'S SHARE OF A CONSENT GROUP CHANGE (AGL-3320).
|
|
19
|
+
*
|
|
20
|
+
* Two things the CRM keeps are read through the CURRENT consent group, so a
|
|
21
|
+
* change to the declaration moves under them unless they move too:
|
|
22
|
+
*
|
|
23
|
+
* - **C4 — a contact's per-site refusal.** `marketingConsentByHost.{site}`
|
|
24
|
+
* holding `marketingConsent: false` refuses mail from every site of that
|
|
25
|
+
* site's group (`readMarketingBasis`). When two sites stop being one
|
|
26
|
+
* sender, each keeps the refusals the other held: the refusal is written
|
|
27
|
+
* onto the receiving site's entry by dotted path, with the one it
|
|
28
|
+
* replaced kept as `supersededEntry`. A refusal is carried whenever it was
|
|
29
|
+
* given — over-carrying is the safe direction — and one the receiving site
|
|
30
|
+
* already holds is left alone. Only contacts carry per-site refusals;
|
|
31
|
+
* leads and list memberships carry grants alone.
|
|
32
|
+
* - **The holder records**, `facets.{groupId}` — a profile, an owner, a
|
|
33
|
+
* stage, notes, order figures and a timeline, keyed by the group that
|
|
34
|
+
* keeps them. Every writer keys by the declaration in force, so after the
|
|
35
|
+
* flip a site's records must be under its new key:
|
|
36
|
+
* - a MOVE folds every old key's facet into the new key's, with the
|
|
37
|
+
* existing target first and then the sources in the order the person
|
|
38
|
+
* met their sites, through `mergeContactFacet` — the survivor's
|
|
39
|
+
* values win, lists union, figures sum — and deletes each source in
|
|
40
|
+
* the same write, so a figure is summed exactly once;
|
|
41
|
+
* - a SPLIT copies the facet, without its figures and with only the
|
|
42
|
+
* timeline entries of the successor's own sites, to each successor
|
|
43
|
+
* that met the person itself, merged under whatever the successor
|
|
44
|
+
* already holds. The figures follow the capture only when every site
|
|
45
|
+
* that met the person lands under one key; otherwise they stay with the
|
|
46
|
+
* surviving key, or — when the key dissolves — are dropped and counted.
|
|
47
|
+
*
|
|
48
|
+
* ## Run twice, write once
|
|
49
|
+
*
|
|
50
|
+
* The executor runs `carry` before the flip and again as the catch-up, and
|
|
51
|
+
* the sweep runs everything once more. A carried refusal is skipped once the
|
|
52
|
+
* receiving entry refuses; a MOVE or a dissolving SPLIT deletes its source in
|
|
53
|
+
* the write that folds it, so a second pass finds nothing — except a
|
|
54
|
+
* straggler a long job wrote under the old key after the first pass, which is
|
|
55
|
+
* exactly what the sweep exists to fold in. A SPLIT whose key survives stays
|
|
56
|
+
* a live key, so each copy is stamped with the change that made it
|
|
57
|
+
* (`copiedByChange`), and a pass that finds the stamp does not copy again.
|
|
58
|
+
*/ import { mergeContactFacet } from "@aglyn/aglyn/app-utils/contact-merge";
|
|
59
|
+
import { CONTACT_FACETS_FIELD, interactionsForGroup } from "@aglyn/aglyn/app-utils/contacts";
|
|
60
|
+
import { COMPANY_CONTACTS_COUNT_FIELD, CONTACT_COMPANY_IDS_FIELD, CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
|
|
61
|
+
import { CAPTURED_BY_HOST_FIELD, CONSENT_GROUP_ID_FIELD, MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD, MARKETING_CONSENT_SOURCE_FIELD } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
62
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
63
|
+
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
64
|
+
import { summarizeConsentGroupChange } from "../model/consent-group-summary.js";
|
|
65
|
+
/** Contacts one page of the refusal carry reads. */ export const REFUSAL_PAGE = 300;
|
|
66
|
+
/** Contacts one page of the holder pass reads. */ export const HOLDER_PAGE = 200;
|
|
67
|
+
/**
|
|
68
|
+
* The most records a preview reads to answer a count no aggregation can:
|
|
69
|
+
* past it, the line says so with a `null` count rather than guess.
|
|
70
|
+
*/ export const PREVIEW_SCAN_LIMIT = 5000;
|
|
71
|
+
/**
|
|
72
|
+
* The figures a facet sums, and the instants that go with them — what
|
|
73
|
+
* `mergeContactFacet` adds up or takes the earliest or latest of. A split
|
|
74
|
+
* cannot divide one person's purchases between two holders, so a copy
|
|
75
|
+
* leaves them behind.
|
|
76
|
+
*/ export const HOLDER_FIGURE_FIELDS = [
|
|
77
|
+
'ltvCents',
|
|
78
|
+
'ordersCount',
|
|
79
|
+
'refundedCents',
|
|
80
|
+
'refundedOrdersCount',
|
|
81
|
+
'firstPurchaseAtMs',
|
|
82
|
+
'lastPurchaseAtMs',
|
|
83
|
+
'lastRefundAtMs',
|
|
84
|
+
'lastEmailEngagementAtMs'
|
|
85
|
+
];
|
|
86
|
+
/** The stamp a split's copy carries: `{ [sourceKey]: changeId }`. */ export const COPIED_BY_CHANGE_FIELD = 'copiedByChange';
|
|
87
|
+
/** The error codes that mean the contact was written since it was read. */ const RACED = new Set([
|
|
88
|
+
5,
|
|
89
|
+
9
|
|
90
|
+
]);
|
|
91
|
+
const codeOf = (error)=>error == null ? void 0 : error.code;
|
|
92
|
+
function mapOf(value) {
|
|
93
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
94
|
+
}
|
|
95
|
+
/** The sites that met this person, in the order they did — then the legacy `hostId`. */ function captureOrder(contact) {
|
|
96
|
+
const raw = contact[CAPTURED_BY_HOST_FIELD];
|
|
97
|
+
const ordered = Array.isArray(raw) ? raw.map((id)=>String(id != null ? id : '').trim()).filter(Boolean) : [];
|
|
98
|
+
const legacy = typeof contact['hostId'] === 'string' ? contact['hostId'] : '';
|
|
99
|
+
return legacy && !ordered.includes(legacy) ? [
|
|
100
|
+
...ordered,
|
|
101
|
+
legacy
|
|
102
|
+
] : ordered;
|
|
103
|
+
}
|
|
104
|
+
function withoutFigures(facet) {
|
|
105
|
+
const out = _extends({}, facet);
|
|
106
|
+
for (const field of HOLDER_FIGURE_FIELDS)delete out[field];
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
function figuresOf(facet) {
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const field of HOLDER_FIGURE_FIELDS){
|
|
112
|
+
if (field in facet) out[field] = facet[field];
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The holder records of one contact after the plan's flows — or `null` when
|
|
118
|
+
* nothing moves.
|
|
119
|
+
*
|
|
120
|
+
* `stragglersOnly` is the sweep: only the flows that delete their source
|
|
121
|
+
* (MOVE, and a SPLIT whose key dissolves) run, because only those can have
|
|
122
|
+
* left anything behind that a later writer recreated. A key that survived a
|
|
123
|
+
* split is a live key after the flip, and its new writes belong to it.
|
|
124
|
+
*/ export function planHolderRecords(contact, plan, changeId, options = {}) {
|
|
125
|
+
const facets = mapOf(contact[CONTACT_FACETS_FIELD]);
|
|
126
|
+
const captured = captureOrder(contact);
|
|
127
|
+
const next = {};
|
|
128
|
+
const current = (key)=>{
|
|
129
|
+
var _next_key;
|
|
130
|
+
return key in next ? (_next_key = next[key]) != null ? _next_key : undefined : facets[key];
|
|
131
|
+
};
|
|
132
|
+
let combined = 0;
|
|
133
|
+
let copied = 0;
|
|
134
|
+
let figuresDropped = 0;
|
|
135
|
+
const splits = plan.flows.filter((flow)=>flow.kind === 'split' && !(options.stragglersOnly && flow.survives));
|
|
136
|
+
const copies = [];
|
|
137
|
+
for (const split of splits){
|
|
138
|
+
const source = facets[split.from];
|
|
139
|
+
if (!source || typeof source !== 'object') continue;
|
|
140
|
+
if (split.survives && split.successors.some((successor)=>{
|
|
141
|
+
var _facets_successor_key;
|
|
142
|
+
return mapOf((_facets_successor_key = facets[successor.key]) == null ? void 0 : _facets_successor_key[COPIED_BY_CHANGE_FIELD])[split.from] === changeId;
|
|
143
|
+
})) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const keyOf = (hostId)=>{
|
|
147
|
+
var _ref;
|
|
148
|
+
var _split_successors_find;
|
|
149
|
+
return split.stayHostIds.includes(hostId) ? split.from : (_ref = (_split_successors_find = split.successors.find((successor)=>successor.hostIds.includes(hostId))) == null ? void 0 : _split_successors_find.key) != null ? _ref : null;
|
|
150
|
+
};
|
|
151
|
+
let eligible = split.successors.filter((successor)=>successor.hostIds.some((hostId)=>captured.includes(hostId)));
|
|
152
|
+
if (!split.survives && !eligible.length) eligible = split.successors;
|
|
153
|
+
const landing = [
|
|
154
|
+
...new Set(captured.filter((hostId)=>split.fromHostIds.includes(hostId)).map(keyOf).filter((key)=>key !== null))
|
|
155
|
+
];
|
|
156
|
+
const figuresTo = landing.length === 1 && landing[0] !== split.from && eligible.some((s)=>s.key === landing[0]) ? landing[0] : null;
|
|
157
|
+
for (const successor of eligible){
|
|
158
|
+
var _source_interactions;
|
|
159
|
+
const copy = _extends({}, withoutFigures(source), {
|
|
160
|
+
interactions: interactionsForGroup((_source_interactions = source['interactions']) != null ? _source_interactions : [], successor.hostIds)
|
|
161
|
+
}, successor.key === figuresTo ? figuresOf(source) : {});
|
|
162
|
+
if (split.survives) copy[COPIED_BY_CHANGE_FIELD] = {
|
|
163
|
+
[split.from]: changeId
|
|
164
|
+
};
|
|
165
|
+
copies.push({
|
|
166
|
+
key: successor.key,
|
|
167
|
+
facet: copy
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (split.survives) {
|
|
171
|
+
var _current;
|
|
172
|
+
if (figuresTo) next[split.from] = withoutFigures((_current = current(split.from)) != null ? _current : source);
|
|
173
|
+
} else {
|
|
174
|
+
next[split.from] = null;
|
|
175
|
+
const figures = figuresOf(source);
|
|
176
|
+
if (!figuresTo && Object.keys(figures).some((field)=>field !== 'lastEmailEngagementAtMs')) {
|
|
177
|
+
figuresDropped += 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const moves = new Map();
|
|
182
|
+
for (const flow of plan.flows){
|
|
183
|
+
var _moves_get;
|
|
184
|
+
if (flow.kind !== 'move' || !facets[flow.from]) continue;
|
|
185
|
+
moves.set(flow.to, [
|
|
186
|
+
...(_moves_get = moves.get(flow.to)) != null ? _moves_get : [],
|
|
187
|
+
flow
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
const metAt = (hostIds)=>{
|
|
191
|
+
const indexes = hostIds.map((hostId)=>captured.indexOf(hostId)).filter((index)=>index >= 0);
|
|
192
|
+
return indexes.length ? Math.min(...indexes) : Number.POSITIVE_INFINITY;
|
|
193
|
+
};
|
|
194
|
+
for (const [target, sources] of moves){
|
|
195
|
+
const ordered = [
|
|
196
|
+
...sources
|
|
197
|
+
].sort((a, b)=>metAt(a.fromHostIds) - metAt(b.fromHostIds) || (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
198
|
+
let folded = current(target);
|
|
199
|
+
// A person is COMBINED when two or more records become one; a lone
|
|
200
|
+
// record under a new key is only moved.
|
|
201
|
+
if (ordered.length + (folded ? 1 : 0) >= 2) combined += 1;
|
|
202
|
+
for (const source of ordered){
|
|
203
|
+
const facet = facets[source.from];
|
|
204
|
+
folded = folded ? mergeContactFacet(folded, facet) : _extends({}, facet);
|
|
205
|
+
next[source.from] = null;
|
|
206
|
+
}
|
|
207
|
+
if (folded) next[target] = folded;
|
|
208
|
+
}
|
|
209
|
+
for (const { key, facet } of copies){
|
|
210
|
+
const existing = current(key);
|
|
211
|
+
const merged = existing ? mergeContactFacet(existing, facet) : facet;
|
|
212
|
+
// The stamps are a union, never the survivor's alone: the merge keeps a
|
|
213
|
+
// map the survivor already has, and a lost stamp would copy again.
|
|
214
|
+
const stamps = _extends({}, mapOf(existing == null ? void 0 : existing[COPIED_BY_CHANGE_FIELD]), mapOf(facet[COPIED_BY_CHANGE_FIELD]));
|
|
215
|
+
if (Object.keys(stamps).length) merged[COPIED_BY_CHANGE_FIELD] = stamps;
|
|
216
|
+
next[key] = merged;
|
|
217
|
+
copied += 1;
|
|
218
|
+
}
|
|
219
|
+
const keys = Object.keys(next);
|
|
220
|
+
if (!keys.length) return null;
|
|
221
|
+
/*
|
|
222
|
+
* The company mirror follows the facets: an id that no facet names any
|
|
223
|
+
* more leaves it, and one a facet newly names joins it. Ids the mirror held
|
|
224
|
+
* without a facet naming them — links older than the facets — are left.
|
|
225
|
+
*/ const named = (source)=>new Set(Object.values(source).map((facet)=>facet ? facet['companyId'] : null).filter((id)=>typeof id === 'string' && id !== ''));
|
|
226
|
+
const after = _extends({}, facets, next);
|
|
227
|
+
const before = named(facets);
|
|
228
|
+
const now = named(after);
|
|
229
|
+
const mirror = Array.isArray(contact[CONTACT_COMPANY_IDS_FIELD]) ? contact[CONTACT_COMPANY_IDS_FIELD].filter((id)=>typeof id === 'string' && id !== '') : [];
|
|
230
|
+
const companyCounts = [];
|
|
231
|
+
const leaving = [
|
|
232
|
+
...before
|
|
233
|
+
].filter((id)=>!now.has(id));
|
|
234
|
+
const joining = [
|
|
235
|
+
...now
|
|
236
|
+
].filter((id)=>!before.has(id));
|
|
237
|
+
for (const id of leaving)if (mirror.includes(id)) companyCounts.push({
|
|
238
|
+
companyId: id,
|
|
239
|
+
delta: -1
|
|
240
|
+
});
|
|
241
|
+
for (const id of joining)if (!mirror.includes(id)) companyCounts.push({
|
|
242
|
+
companyId: id,
|
|
243
|
+
delta: 1
|
|
244
|
+
});
|
|
245
|
+
const patch = {};
|
|
246
|
+
for (const key of keys){
|
|
247
|
+
patch[`${CONTACT_FACETS_FIELD}.${key}`] = next[key] === null ? FieldValue.delete() : next[key];
|
|
248
|
+
}
|
|
249
|
+
if (companyCounts.length) {
|
|
250
|
+
patch[CONTACT_COMPANY_IDS_FIELD] = [
|
|
251
|
+
...mirror.filter((id)=>!leaving.includes(id)),
|
|
252
|
+
...joining.filter((id)=>!mirror.includes(id))
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
patch['updatedAt'] = FieldValue.serverTimestamp();
|
|
256
|
+
return {
|
|
257
|
+
patch,
|
|
258
|
+
combined,
|
|
259
|
+
copied,
|
|
260
|
+
figuresDropped,
|
|
261
|
+
companyCounts
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The refusal `from` holds, carried onto `to` — or `null` when `to` already
|
|
266
|
+
* refuses or `from` does not.
|
|
267
|
+
*/ export function planRefusalCarry(contact, carry, changeId) {
|
|
268
|
+
const byHost = mapOf(contact[MARKETING_CONSENT_BY_HOST_FIELD]);
|
|
269
|
+
const source = mapOf(byHost[carry.fromHostId]);
|
|
270
|
+
if (source[MARKETING_CONSENT_FIELD] !== false) return null;
|
|
271
|
+
const target = byHost[carry.toHostId];
|
|
272
|
+
if (mapOf(target)[MARKETING_CONSENT_FIELD] === false) return null;
|
|
273
|
+
const entry = _extends({
|
|
274
|
+
[MARKETING_CONSENT_FIELD]: false
|
|
275
|
+
}, source['marketingConsentAtMs'] !== undefined ? {
|
|
276
|
+
marketingConsentAtMs: source['marketingConsentAtMs']
|
|
277
|
+
} : {}, source[MARKETING_CONSENT_SOURCE_FIELD] !== undefined ? {
|
|
278
|
+
[MARKETING_CONSENT_SOURCE_FIELD]: source[MARKETING_CONSENT_SOURCE_FIELD]
|
|
279
|
+
} : {}, {
|
|
280
|
+
carriedFromHostId: carry.fromHostId,
|
|
281
|
+
carriedByChangeId: changeId
|
|
282
|
+
}, target && typeof target === 'object' ? {
|
|
283
|
+
supersededEntry: target
|
|
284
|
+
} : {});
|
|
285
|
+
return {
|
|
286
|
+
[`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.toHostId}`]: entry,
|
|
287
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function readCursor(raw, first) {
|
|
291
|
+
if (raw) {
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(raw);
|
|
294
|
+
if (parsed && (parsed.stage === 'refusals' || parsed.stage === 'holders')) return parsed;
|
|
295
|
+
} catch (unused) {
|
|
296
|
+
// A cursor this participant did not write starts the phase over, which
|
|
297
|
+
// every pass is safe to do.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
stage: first,
|
|
302
|
+
carry: 0,
|
|
303
|
+
after: null
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Writes a page's patches, each with a `lastUpdateTime` precondition, and
|
|
308
|
+
* recomputes the ones that raced once from a fresh read. Answers how many
|
|
309
|
+
* were written.
|
|
310
|
+
*/ async function writeGuarded(firestore, pending, recompute) {
|
|
311
|
+
let written = 0;
|
|
312
|
+
let due = pending;
|
|
313
|
+
for(let round = 0; round < 2 && due.length; round += 1){
|
|
314
|
+
const writer = firestore.bulkWriter();
|
|
315
|
+
const outcomes = due.map((entry)=>writer.update(entry.snapshot.ref, entry.patch, {
|
|
316
|
+
lastUpdateTime: entry.snapshot.updateTime
|
|
317
|
+
}).then(()=>({
|
|
318
|
+
entry,
|
|
319
|
+
error: null
|
|
320
|
+
}), (error)=>({
|
|
321
|
+
entry,
|
|
322
|
+
error: error != null ? error : new Error('The write failed')
|
|
323
|
+
})));
|
|
324
|
+
await writer.close();
|
|
325
|
+
const raced = [];
|
|
326
|
+
for (const { entry, error } of (await Promise.all(outcomes))){
|
|
327
|
+
if (error === null) {
|
|
328
|
+
written += 1;
|
|
329
|
+
await (entry.after == null ? void 0 : entry.after.call(entry));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const code = codeOf(error);
|
|
333
|
+
if (code === undefined || !RACED.has(code)) throw error;
|
|
334
|
+
raced.push(entry);
|
|
335
|
+
}
|
|
336
|
+
if (!raced.length) return written;
|
|
337
|
+
const fresh = await firestore.getAll(...raced.map((entry)=>entry.snapshot.ref));
|
|
338
|
+
due = fresh.filter((snapshot)=>snapshot.exists).map(recompute).filter((entry)=>entry !== null);
|
|
339
|
+
}
|
|
340
|
+
if (due.length) {
|
|
341
|
+
throw new Error(`[crm] ${due.length} contact(s) kept changing under the consent group change`);
|
|
342
|
+
}
|
|
343
|
+
return written;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The CRM's participant. `firestore` and `now` are injectable for the specs;
|
|
347
|
+
* the defaults are the Admin SDK's and the wall clock.
|
|
348
|
+
*/ export function createConsentGroupParticipant(options = {}) {
|
|
349
|
+
var _options_now;
|
|
350
|
+
const db = ()=>{
|
|
351
|
+
var _options_firestore;
|
|
352
|
+
return ((_options_firestore = options.firestore) != null ? _options_firestore : ()=>firebaseAdmin.app().firestore())();
|
|
353
|
+
};
|
|
354
|
+
const now = (_options_now = options.now) != null ? _options_now : Date.now;
|
|
355
|
+
const contactsOf = (orgId)=>db().collection('orgs').doc(orgId).collection('contacts');
|
|
356
|
+
async function countOf(query) {
|
|
357
|
+
return Number((await query.count().get()).data().count) || 0;
|
|
358
|
+
}
|
|
359
|
+
/** One page of one carry's refusals. */ async function refusalPage(request, cursor, counts) {
|
|
360
|
+
var _counts_refusals;
|
|
361
|
+
const carries = request.plan.carries;
|
|
362
|
+
if (cursor.carry >= carries.length) return null;
|
|
363
|
+
const carry = carries[cursor.carry];
|
|
364
|
+
let query = contactsOf(request.orgId).where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.fromHostId}.${MARKETING_CONSENT_FIELD}`, '==', false).orderBy(FieldPath.documentId()).limit(REFUSAL_PAGE);
|
|
365
|
+
if (cursor.after) query = query.startAfter(cursor.after);
|
|
366
|
+
const page = await query.get();
|
|
367
|
+
const plan = (snapshot)=>{
|
|
368
|
+
var _snapshot_data;
|
|
369
|
+
const patch = planRefusalCarry((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}, carry, request.changeId);
|
|
370
|
+
return patch ? {
|
|
371
|
+
snapshot,
|
|
372
|
+
patch
|
|
373
|
+
} : null;
|
|
374
|
+
};
|
|
375
|
+
const pending = page.docs.map(plan).filter((entry)=>entry !== null);
|
|
376
|
+
counts['refusals'] = ((_counts_refusals = counts['refusals']) != null ? _counts_refusals : 0) + (request.dryRun ? pending.length : await writeGuarded(db(), pending, plan));
|
|
377
|
+
if (page.size < REFUSAL_PAGE) return {
|
|
378
|
+
stage: 'refusals',
|
|
379
|
+
carry: cursor.carry + 1,
|
|
380
|
+
after: null
|
|
381
|
+
};
|
|
382
|
+
return {
|
|
383
|
+
stage: 'refusals',
|
|
384
|
+
carry: cursor.carry,
|
|
385
|
+
after: page.docs[page.docs.length - 1].id
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/** One page of the holder pass over every contact. */ async function holderPage(request, cursor, counts) {
|
|
389
|
+
var _counts_combined, _counts_copied, _counts_figuresDropped;
|
|
390
|
+
let query = contactsOf(request.orgId).orderBy(FieldPath.documentId()).limit(HOLDER_PAGE);
|
|
391
|
+
if (cursor.after) query = query.startAfter(cursor.after);
|
|
392
|
+
const page = await query.get();
|
|
393
|
+
const companies = db().collection('orgs').doc(request.orgId).collection(CRM_COLLECTIONS.companies);
|
|
394
|
+
const stragglersOnly = request.phase === 'sweep';
|
|
395
|
+
const tally = {
|
|
396
|
+
combined: 0,
|
|
397
|
+
copied: 0,
|
|
398
|
+
figuresDropped: 0
|
|
399
|
+
};
|
|
400
|
+
const count = (planned)=>{
|
|
401
|
+
tally.combined += planned.combined;
|
|
402
|
+
tally.copied += planned.copied;
|
|
403
|
+
tally.figuresDropped += planned.figuresDropped;
|
|
404
|
+
};
|
|
405
|
+
const plan = (snapshot)=>{
|
|
406
|
+
var _snapshot_data;
|
|
407
|
+
const planned = planHolderRecords((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}, request.plan, request.changeId, {
|
|
408
|
+
stragglersOnly
|
|
409
|
+
});
|
|
410
|
+
if (!planned) return null;
|
|
411
|
+
return {
|
|
412
|
+
snapshot,
|
|
413
|
+
planned,
|
|
414
|
+
patch: planned.patch,
|
|
415
|
+
after: async ()=>{
|
|
416
|
+
count(planned);
|
|
417
|
+
// A count that could not move is logged and left: the company's own
|
|
418
|
+
// page takes the live aggregate, which corrects it.
|
|
419
|
+
await Promise.all(planned.companyCounts.map((change)=>companies.doc(change.companyId).update({
|
|
420
|
+
[COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(change.delta)
|
|
421
|
+
}).catch((error)=>{
|
|
422
|
+
console.error('[crm] company contacts count could not move', change.companyId, error);
|
|
423
|
+
})));
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
const pending = page.docs.map(plan).filter((entry)=>entry !== null);
|
|
428
|
+
if (request.dryRun) for (const entry of pending)count(entry.planned);
|
|
429
|
+
else await writeGuarded(db(), pending, plan);
|
|
430
|
+
counts['combined'] = ((_counts_combined = counts['combined']) != null ? _counts_combined : 0) + tally.combined;
|
|
431
|
+
counts['copied'] = ((_counts_copied = counts['copied']) != null ? _counts_copied : 0) + tally.copied;
|
|
432
|
+
counts['figuresDropped'] = ((_counts_figuresDropped = counts['figuresDropped']) != null ? _counts_figuresDropped : 0) + tally.figuresDropped;
|
|
433
|
+
if (page.size < HOLDER_PAGE) return null;
|
|
434
|
+
return {
|
|
435
|
+
stage: 'holders',
|
|
436
|
+
carry: 0,
|
|
437
|
+
after: page.docs[page.docs.length - 1].id
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
async run (request) {
|
|
442
|
+
const counts = {};
|
|
443
|
+
const hasHolderFlows = request.plan.flows.length > 0;
|
|
444
|
+
// `carry` walks the refusals; `rehome` the holders; `sweep` both.
|
|
445
|
+
let cursor = readCursor(request.cursor, request.phase === 'rehome' ? 'holders' : 'refusals');
|
|
446
|
+
while(cursor && now() < request.deadlineMs){
|
|
447
|
+
if (cursor.stage === 'refusals') {
|
|
448
|
+
const next = await refusalPage(request, cursor, counts);
|
|
449
|
+
cursor = next != null ? next : request.phase === 'sweep' && hasHolderFlows ? {
|
|
450
|
+
stage: 'holders',
|
|
451
|
+
carry: 0,
|
|
452
|
+
after: null
|
|
453
|
+
} : null;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
cursor = hasHolderFlows ? await holderPage(request, cursor, counts) : null;
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
done: cursor === null,
|
|
460
|
+
cursor: cursor ? JSON.stringify(cursor) : null,
|
|
461
|
+
counts
|
|
462
|
+
};
|
|
463
|
+
},
|
|
464
|
+
async preview ({ orgId, plan }) {
|
|
465
|
+
const contacts = contactsOf(orgId);
|
|
466
|
+
const lines = [];
|
|
467
|
+
const moves = plan.flows.filter((flow)=>flow.kind === 'move');
|
|
468
|
+
const splits = plan.flows.filter((flow)=>flow.kind === 'split');
|
|
469
|
+
const people = (count)=>count === 1 ? 'person' : 'people';
|
|
470
|
+
const figure = (count)=>count === null ? 'some' : String(count);
|
|
471
|
+
/*
|
|
472
|
+
* `crm.combine`: the people whose records under two or more of the
|
|
473
|
+
* folding keys become one. No aggregation can ask "two or more", so the
|
|
474
|
+
* records under each source key are read — bounded, and `null` past
|
|
475
|
+
* the bound — and put through the same fold the re-home runs.
|
|
476
|
+
*/ if (moves.length) {
|
|
477
|
+
const seen = new Map();
|
|
478
|
+
let bounded = true;
|
|
479
|
+
for (const move of moves){
|
|
480
|
+
var _doc_data;
|
|
481
|
+
const page = await contacts.where(`${CONTACT_FACETS_FIELD}.${move.from}`, '!=', null).limit(PREVIEW_SCAN_LIMIT + 1).get();
|
|
482
|
+
if (page.size > PREVIEW_SCAN_LIMIT) bounded = false;
|
|
483
|
+
for (const doc of page.docs)seen.set(doc.id, (_doc_data = doc.data()) != null ? _doc_data : {});
|
|
484
|
+
}
|
|
485
|
+
const combine = bounded ? [
|
|
486
|
+
...seen.values()
|
|
487
|
+
].reduce((total, contact)=>{
|
|
488
|
+
var _ref;
|
|
489
|
+
var _planHolderRecords;
|
|
490
|
+
return total + ((_ref = (_planHolderRecords = planHolderRecords(contact, {
|
|
491
|
+
flows: moves
|
|
492
|
+
}, '')) == null ? void 0 : _planHolderRecords.combined) != null ? _ref : 0);
|
|
493
|
+
}, 0) : null;
|
|
494
|
+
lines.push({
|
|
495
|
+
id: 'crm.combine',
|
|
496
|
+
text: `The CRM records these sites keep about the same person will be combined for ${figure(combine)} ${people(combine)}.`,
|
|
497
|
+
count: combine,
|
|
498
|
+
severity: 'info'
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (splits.length) {
|
|
502
|
+
/*
|
|
503
|
+
* `crm.copy`: the people a separating site takes a copy for. When the
|
|
504
|
+
* key survives, those are the people the separating sites met
|
|
505
|
+
* themselves; when it dissolves, everybody it holds, since a record
|
|
506
|
+
* nobody met goes to every successor.
|
|
507
|
+
*/ let copy = 0;
|
|
508
|
+
for (const split of splits){
|
|
509
|
+
if (!split.survives) {
|
|
510
|
+
copy += await countOf(contacts.where(`${CONTACT_FACETS_FIELD}.${split.from}`, '!=', null));
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const leavers = split.successors.flatMap((successor)=>successor.hostIds);
|
|
514
|
+
for(let index = 0; index < leavers.length; index += 30){
|
|
515
|
+
copy += await countOf(contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains-any', leavers.slice(index, index + 30)));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
lines.push({
|
|
519
|
+
id: 'crm.copy',
|
|
520
|
+
text: `The separating sites keep a copy of the CRM record for the ${copy} ${people(copy)} they met themselves.`,
|
|
521
|
+
count: copy,
|
|
522
|
+
severity: 'info'
|
|
523
|
+
});
|
|
524
|
+
/*
|
|
525
|
+
* `crm.grants` and `crm.visibility`, per separating site: the people
|
|
526
|
+
* who signed up to the group through it and still hold that grant,
|
|
527
|
+
* and the people it can see without having met them.
|
|
528
|
+
*/ let grants = 0;
|
|
529
|
+
let visible = 0;
|
|
530
|
+
for (const split of splits){
|
|
531
|
+
for (const hostId of split.successors.flatMap((successor)=>successor.hostIds)){
|
|
532
|
+
grants += await countOf(contacts.where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${CONSENT_GROUP_ID_FIELD}`, '==', split.from).where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`, '==', true));
|
|
533
|
+
const seen = await countOf(contacts.where('visibleTo', 'array-contains', `host:${hostId}`));
|
|
534
|
+
const met = await countOf(contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId));
|
|
535
|
+
visible += Math.max(0, seen - met);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
lines.push({
|
|
539
|
+
id: 'crm.grants',
|
|
540
|
+
text: `The separating sites can still email the ${grants} ${people(grants)} who signed up to the group while they were part of it.`,
|
|
541
|
+
count: grants,
|
|
542
|
+
severity: 'info'
|
|
543
|
+
});
|
|
544
|
+
lines.push({
|
|
545
|
+
id: 'crm.visibility',
|
|
546
|
+
text: `The separating sites will still see ${visible} ${people(visible)} they met only through the group's other sites, without their CRM details.`,
|
|
547
|
+
count: visible,
|
|
548
|
+
severity: 'info'
|
|
549
|
+
});
|
|
550
|
+
/*
|
|
551
|
+
* `crm.figures`: on a dissolve, the people whose order totals cannot
|
|
552
|
+
* follow one site and are dropped. Read like the combine — the people
|
|
553
|
+
* with orders under the key, bounded — and decided by the same split.
|
|
554
|
+
*/ const dissolving = splits.filter((split)=>!split.survives);
|
|
555
|
+
if (dissolving.length) {
|
|
556
|
+
let figures = 0;
|
|
557
|
+
for (const split of dissolving){
|
|
558
|
+
const page = await contacts.where(`${CONTACT_FACETS_FIELD}.${split.from}.ordersCount`, '>', 0).limit(PREVIEW_SCAN_LIMIT + 1).get();
|
|
559
|
+
if (page.size > PREVIEW_SCAN_LIMIT || figures === null) {
|
|
560
|
+
figures = null;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
for (const doc of page.docs){
|
|
564
|
+
var _ref, _doc_data1;
|
|
565
|
+
var _planHolderRecords;
|
|
566
|
+
figures += (_ref = (_planHolderRecords = planHolderRecords((_doc_data1 = doc.data()) != null ? _doc_data1 : {}, {
|
|
567
|
+
flows: [
|
|
568
|
+
split
|
|
569
|
+
]
|
|
570
|
+
}, '')) == null ? void 0 : _planHolderRecords.figuresDropped) != null ? _ref : 0;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
lines.push({
|
|
574
|
+
id: 'crm.figures',
|
|
575
|
+
text: `Order totals for ${figure(figures)} ${people(figures)} who bought on more than one of these sites can't be split and will be removed.`,
|
|
576
|
+
count: figures,
|
|
577
|
+
severity: 'warning'
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/*
|
|
582
|
+
* `crm.refusals`: the refusals C4 would write — each separating pair's
|
|
583
|
+
* declined consents, less the ones the receiving site already holds.
|
|
584
|
+
*/ if (plan.carries.length) {
|
|
585
|
+
let refusals = 0;
|
|
586
|
+
for (const carry of plan.carries){
|
|
587
|
+
const declined = (hostId)=>`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`;
|
|
588
|
+
const from = contacts.where(declined(carry.fromHostId), '==', false);
|
|
589
|
+
refusals += Math.max(0, await countOf(from) - await countOf(from.where(declined(carry.toHostId), '==', false)));
|
|
590
|
+
}
|
|
591
|
+
lines.push({
|
|
592
|
+
id: 'crm.refusals',
|
|
593
|
+
text: `${refusals} declined ${refusals === 1 ? 'consent' : 'consents'} will be copied to the sites that separate.`,
|
|
594
|
+
count: refusals,
|
|
595
|
+
severity: 'info'
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return lines;
|
|
599
|
+
},
|
|
600
|
+
summarize: summarizeConsentGroupChange
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** The participant the CRM registers, on the Admin SDK and the wall clock. */ export const consentGroupParticipant = createConsentGroupParticipant();
|
|
604
|
+
|
|
605
|
+
//# sourceMappingURL=consent-group-participant.js.map
|