@aglyn/aglyn 1.0.0-beta.196 → 1.0.0-beta.198
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 +11 -11
- package/src/lib/app-utils/consent-group-change.d.ts +519 -0
- package/src/lib/app-utils/consent-group-change.js +671 -0
- package/src/lib/app-utils/consent-group-change.js.map +1 -0
- package/src/lib/app-utils/consent-groups.d.ts +106 -8
- package/src/lib/app-utils/consent-groups.js +163 -8
- package/src/lib/app-utils/consent-groups.js.map +1 -1
- package/src/lib/app-utils/contact-merge.js +37 -4
- package/src/lib/app-utils/contact-merge.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +7 -7
- package/src/lib/app-utils/docs-help.generated.js +14 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +49 -35
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/health-report.js +13 -0
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/marketing-consent.d.ts +59 -0
- package/src/lib/app-utils/marketing-consent.js +86 -0
- package/src/lib/app-utils/marketing-consent.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +22 -3
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/plugin-manager/plugin-consent-group-change.d.ts +116 -0
- package/src/lib/plugin-manager/plugin-consent-group-change.js +113 -0
- package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
|
@@ -0,0 +1,671 @@
|
|
|
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
|
+
* CHANGING WHICH SITES ARE ONE SENDER (AGL-3320) — the pure half.
|
|
19
|
+
*
|
|
20
|
+
* `consent-groups.ts` reads a declaration; this is what a change to one
|
|
21
|
+
* means. It validates the next declaration an admin submits, decides what has
|
|
22
|
+
* to move for the sites to stop or start being one sender, and words the
|
|
23
|
+
* result — and it does all of that without a read, so the console's review
|
|
24
|
+
* step, the route's refusal and the executor's plan are one computation.
|
|
25
|
+
*
|
|
26
|
+
* ## Why a change is a plan and not a write
|
|
27
|
+
*
|
|
28
|
+
* A declaration is read at send time, so flipping it is instant — and that is
|
|
29
|
+
* exactly the problem. Two facts are stored per SITE and read across the
|
|
30
|
+
* CURRENT group: a refusal (a suppression row, a topic opt-out, a pace, a
|
|
31
|
+
* declined consent) and a holder's business record (the CRM facet keyed by
|
|
32
|
+
* the group). When sites stop being one sender, each stops reading the
|
|
33
|
+
* other's refusals; when they become one, the records they kept apart must
|
|
34
|
+
* become one. The flip itself moves neither, so every change is:
|
|
35
|
+
*
|
|
36
|
+
* 1. CARRY the refusals onto the sites that are about to stop reading them,
|
|
37
|
+
* in BOTH directions — the person who left one site of a sender was told
|
|
38
|
+
* the opt-out covers all of them, so the site leaving keeps what it
|
|
39
|
+
* honored and the sites staying keep what the leaver honored;
|
|
40
|
+
* 2. FLIP the declaration;
|
|
41
|
+
* 3. RE-HOME the holder records keyed by the old group ids.
|
|
42
|
+
*
|
|
43
|
+
* {@link planConsentGroupChange} says which pairs carry and which holder keys
|
|
44
|
+
* move or split; the executor in `libs/tenant/data/admin` performs it.
|
|
45
|
+
*
|
|
46
|
+
* ## What the planner never touches
|
|
47
|
+
*
|
|
48
|
+
* Grants. A grant is written forward, one entry per disclosed site, and is
|
|
49
|
+
* evidence of what a person was told, so a change never copies, re-keys or
|
|
50
|
+
* revokes one — see `consent-groups.ts`, "Pooling applies FORWARD only".
|
|
51
|
+
*/ import { consentGroupDisclosure, CONSENT_GROUPS_FIELD, MAX_CONSENT_GROUP_HOSTS, readConsentGroups } from "./consent-groups.js";
|
|
52
|
+
/**
|
|
53
|
+
* The org field that marks a change in flight:
|
|
54
|
+
* {@link ConsentGroupsChangeMarker}. Authoritative — the job document holds
|
|
55
|
+
* the detail, and this says whether there is a job at all.
|
|
56
|
+
*
|
|
57
|
+
* On the org document, beside the declaration it guards, so the transaction
|
|
58
|
+
* that starts a change and the one that flips the declaration read one
|
|
59
|
+
* document. Server-owned: denied to every client by the rules.
|
|
60
|
+
*/ export const CONSENT_GROUPS_CHANGE_FIELD = 'consentGroupsChange';
|
|
61
|
+
/** `orgs/{orgId}/consentGroupChanges/{changeId}` — one job per change. */ export const CONSENT_GROUP_CHANGES_COLLECTION = 'consentGroupChanges';
|
|
62
|
+
/**
|
|
63
|
+
* The longest group name a declaration may carry.
|
|
64
|
+
*
|
|
65
|
+
* The name is rendered on every signup form of the group, inside a sentence,
|
|
66
|
+
* so it is a brand and not a description.
|
|
67
|
+
*/ export const CONSENT_GROUP_NAME_MAX = 80;
|
|
68
|
+
/**
|
|
69
|
+
* How long after the flip the sweep waits before its final pass.
|
|
70
|
+
*
|
|
71
|
+
* A long job — a campaign batch, a dynamic list, an outreach tick — resolves
|
|
72
|
+
* the group once per batch and runs for at most five minutes, so a write it
|
|
73
|
+
* made against the OLD declaration can land after the flip. Six minutes is
|
|
74
|
+
* past the longest of them.
|
|
75
|
+
*/ export const CONSENT_GROUP_SWEEP_DELAY_MS = 6 * 60000;
|
|
76
|
+
/**
|
|
77
|
+
* What every minted group id starts with.
|
|
78
|
+
*
|
|
79
|
+
* A group id is a holder key beside the site ids a solo site is keyed by, so
|
|
80
|
+
* the two must never meet. Firestore's automatic ids — which every site id is
|
|
81
|
+
* — are alphanumeric, and an underscore cannot occur in one.
|
|
82
|
+
*/ export const CONSENT_GROUP_ID_PREFIX = 'cg_';
|
|
83
|
+
/**
|
|
84
|
+
* The sites an organization owns, from its `hosts` map, sorted.
|
|
85
|
+
*
|
|
86
|
+
* The map is `{ [hostId]: true }`; an array is read too, so a hand-built
|
|
87
|
+
* fixture and the stored shape agree.
|
|
88
|
+
*/ export function orgSiteIds(org) {
|
|
89
|
+
const hosts = (org != null ? org : {})['hosts'];
|
|
90
|
+
const ids = Array.isArray(hosts) ? hosts.map((id)=>String(id != null ? id : '')) : hosts && typeof hosts === 'object' ? Object.entries(hosts).filter(([, value])=>value !== false && value != null).map(([id])=>id) : [];
|
|
91
|
+
return [
|
|
92
|
+
...new Set(ids.map((id)=>id.trim()).filter(Boolean))
|
|
93
|
+
].sort();
|
|
94
|
+
}
|
|
95
|
+
/** The raw declaration's ids that `readConsentGroups` drops, sorted. */ export function discardedConsentGroupIds(org) {
|
|
96
|
+
const raw = (org != null ? org : {})[CONSENT_GROUPS_FIELD];
|
|
97
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
|
|
98
|
+
const usable = readConsentGroups(org);
|
|
99
|
+
return Object.keys(raw).filter((id)=>!(id in usable)).sort();
|
|
100
|
+
}
|
|
101
|
+
const BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
102
|
+
/** Uniform in [0, 1), from the platform's CSPRNG where there is one. */ function defaultRandom() {
|
|
103
|
+
const crypto = globalThis.crypto;
|
|
104
|
+
if (crypto == null ? void 0 : crypto.getRandomValues) {
|
|
105
|
+
const word = crypto.getRandomValues(new Uint32Array(1))[0];
|
|
106
|
+
return word / 2 ** 32;
|
|
107
|
+
}
|
|
108
|
+
return Math.random();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A fresh group id: {@link CONSENT_GROUP_ID_PREFIX} and sixteen base-36
|
|
112
|
+
* characters, never one in `taken`.
|
|
113
|
+
*
|
|
114
|
+
* `taken` holds every id the id must not equal — the current groups', the
|
|
115
|
+
* raw declaration's, every site's and every id already minted for the same
|
|
116
|
+
* declaration. Eighty bits make a collision a formality, and the loop makes it
|
|
117
|
+
* impossible.
|
|
118
|
+
*/ export function mintConsentGroupId(taken, random = defaultRandom) {
|
|
119
|
+
for(;;){
|
|
120
|
+
let id = CONSENT_GROUP_ID_PREFIX;
|
|
121
|
+
for(let index = 0; index < 16; index += 1){
|
|
122
|
+
id += BASE36[Math.min(35, Math.floor(random() * 36))];
|
|
123
|
+
}
|
|
124
|
+
if (!taken.has(id)) return id;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** A group's canonical form: its name trimmed, its sites unique and sorted. */ function canonicalGroup(group) {
|
|
128
|
+
const value = group && typeof group === 'object' ? group : {};
|
|
129
|
+
const name = typeof value['name'] === 'string' ? value['name'].trim() : '';
|
|
130
|
+
const hostIds = Array.isArray(value['hostIds']) ? [
|
|
131
|
+
...new Set(value['hostIds'].map((id)=>String(id != null ? id : '').trim()).filter(Boolean))
|
|
132
|
+
].sort() : [];
|
|
133
|
+
return {
|
|
134
|
+
name,
|
|
135
|
+
hostIds
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether two declarations say the same thing: the same ids, each with the
|
|
140
|
+
* same trimmed name and the same set of sites. Order never matters.
|
|
141
|
+
*/ export function consentGroupsEqual(a, b) {
|
|
142
|
+
return consentGroupsFingerprint(a) === consentGroupsFingerprint(b);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A declaration as one canonical string: what "the value the editor
|
|
146
|
+
* rendered" and "the value stored now" are compared by.
|
|
147
|
+
*
|
|
148
|
+
* Every entry is kept, usable or not, because the question is whether the
|
|
149
|
+
* stored value CHANGED, and an edit to an entry `readConsentGroups` drops is
|
|
150
|
+
* still somebody else's edit. Absent, `null` and `{}` are one value — an
|
|
151
|
+
* organization with no groups.
|
|
152
|
+
*/ export function consentGroupsFingerprint(raw) {
|
|
153
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'null';
|
|
154
|
+
const entries = Object.entries(raw).map(([id, group])=>[
|
|
155
|
+
id,
|
|
156
|
+
canonicalGroup(group)
|
|
157
|
+
]).sort(([a], [b])=>a < b ? -1 : a > b ? 1 : 0);
|
|
158
|
+
return entries.length ? JSON.stringify(entries) : 'null';
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The next declaration, checked against the organization's sites and the
|
|
162
|
+
* declaration in force, with an id minted for every new group.
|
|
163
|
+
*
|
|
164
|
+
* Refuses everything in the list on {@link ConsentGroupValidationCode}, and
|
|
165
|
+
* reports every problem it finds rather than the first, so the editor can
|
|
166
|
+
* mark each draft at once.
|
|
167
|
+
*/ export function validateConsentGroupDeclaration(input) {
|
|
168
|
+
var _input_reserved;
|
|
169
|
+
const { before } = input;
|
|
170
|
+
if (!Array.isArray(input.groups)) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
errors: [
|
|
174
|
+
{
|
|
175
|
+
code: 'malformed'
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const sites = new Set(input.siteIds);
|
|
181
|
+
const errors = [];
|
|
182
|
+
const names = new Set();
|
|
183
|
+
const ids = new Set();
|
|
184
|
+
const claimed = new Set();
|
|
185
|
+
const drafts = [];
|
|
186
|
+
input.groups.forEach((value, groupIndex)=>{
|
|
187
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
188
|
+
errors.push({
|
|
189
|
+
code: 'malformed',
|
|
190
|
+
groupIndex
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const entry = value;
|
|
195
|
+
if (typeof entry['name'] !== 'string' || !Array.isArray(entry['hostIds'])) {
|
|
196
|
+
errors.push({
|
|
197
|
+
code: 'malformed',
|
|
198
|
+
groupIndex
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const { name, hostIds } = canonicalGroup(entry);
|
|
203
|
+
if (!name) errors.push({
|
|
204
|
+
code: 'name-empty',
|
|
205
|
+
groupIndex
|
|
206
|
+
});
|
|
207
|
+
else if (name.length > CONSENT_GROUP_NAME_MAX) {
|
|
208
|
+
errors.push({
|
|
209
|
+
code: 'name-too-long',
|
|
210
|
+
groupIndex
|
|
211
|
+
});
|
|
212
|
+
} else if (names.has(name.toLowerCase())) {
|
|
213
|
+
errors.push({
|
|
214
|
+
code: 'name-duplicate',
|
|
215
|
+
groupIndex
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (name) names.add(name.toLowerCase());
|
|
219
|
+
for (const hostId of hostIds){
|
|
220
|
+
if (!sites.has(hostId)) {
|
|
221
|
+
errors.push({
|
|
222
|
+
code: 'unknown-site',
|
|
223
|
+
groupIndex,
|
|
224
|
+
hostId
|
|
225
|
+
});
|
|
226
|
+
} else if (claimed.has(hostId)) {
|
|
227
|
+
errors.push({
|
|
228
|
+
code: 'site-in-two-groups',
|
|
229
|
+
groupIndex,
|
|
230
|
+
hostId
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
claimed.add(hostId);
|
|
234
|
+
}
|
|
235
|
+
if (hostIds.length < 2) errors.push({
|
|
236
|
+
code: 'too-few-sites',
|
|
237
|
+
groupIndex
|
|
238
|
+
});
|
|
239
|
+
else if (hostIds.length > MAX_CONSENT_GROUP_HOSTS) {
|
|
240
|
+
errors.push({
|
|
241
|
+
code: 'too-many-sites',
|
|
242
|
+
groupIndex
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const rawId = entry['id'];
|
|
246
|
+
const id = typeof rawId === 'string' && rawId.trim() ? rawId.trim() : null;
|
|
247
|
+
if (id !== null) {
|
|
248
|
+
if (ids.has(id)) errors.push({
|
|
249
|
+
code: 'duplicate-group',
|
|
250
|
+
groupIndex
|
|
251
|
+
});
|
|
252
|
+
else if (!(id in before)) errors.push({
|
|
253
|
+
code: 'unknown-group',
|
|
254
|
+
groupIndex
|
|
255
|
+
});
|
|
256
|
+
else if (!before[id].hostIds.some((hostId)=>hostIds.includes(hostId))) {
|
|
257
|
+
errors.push({
|
|
258
|
+
code: 'group-replaced',
|
|
259
|
+
groupIndex
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
ids.add(id);
|
|
263
|
+
}
|
|
264
|
+
drafts.push({
|
|
265
|
+
id,
|
|
266
|
+
name,
|
|
267
|
+
hostIds
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
if (errors.length) return {
|
|
271
|
+
ok: false,
|
|
272
|
+
errors
|
|
273
|
+
};
|
|
274
|
+
const taken = new Set([
|
|
275
|
+
...Object.keys(before),
|
|
276
|
+
...input.siteIds,
|
|
277
|
+
...(_input_reserved = input.reserved) != null ? _input_reserved : [],
|
|
278
|
+
...ids
|
|
279
|
+
]);
|
|
280
|
+
const after = {};
|
|
281
|
+
for (const draft of drafts){
|
|
282
|
+
var _draft_id;
|
|
283
|
+
const id = (_draft_id = draft.id) != null ? _draft_id : mintConsentGroupId(taken, input.random);
|
|
284
|
+
taken.add(id);
|
|
285
|
+
after[id] = {
|
|
286
|
+
name: draft.name,
|
|
287
|
+
hostIds: draft.hostIds
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (consentGroupsEqual(before, after)) {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
errors: [
|
|
294
|
+
{
|
|
295
|
+
code: 'no-change'
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
after
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function groupOf(declaration) {
|
|
306
|
+
const map = new Map();
|
|
307
|
+
for (const [id, group] of Object.entries(declaration)){
|
|
308
|
+
for (const hostId of group.hostIds)map.set(hostId, {
|
|
309
|
+
id,
|
|
310
|
+
hostIds: group.hostIds
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
315
|
+
const byString = (a, b)=>a < b ? -1 : a > b ? 1 : 0;
|
|
316
|
+
/** `declaration` without the sites `keep` does not name. */ function withSitesOnly(declaration, keep) {
|
|
317
|
+
const out = {};
|
|
318
|
+
for (const [id, group] of Object.entries(declaration)){
|
|
319
|
+
const hostIds = group.hostIds.filter((hostId)=>keep.has(hostId));
|
|
320
|
+
if (hostIds.length) out[id] = {
|
|
321
|
+
name: group.name,
|
|
322
|
+
hostIds
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* What moving from `before` to `after` requires.
|
|
329
|
+
*
|
|
330
|
+
* `key(s)` is the holder key of site `s` — its group's id, or `s` itself when
|
|
331
|
+
* it is alone — and `members(s)` the sites of that group, or `[s]`.
|
|
332
|
+
*
|
|
333
|
+
* - **Carries.** For every pair a ≠ b that are one sender now and will not
|
|
334
|
+
* be, `b`'s refusals are copied onto `a`. Pairs that stay together, and
|
|
335
|
+
* pairs that come together, need nothing: a joined site reads its new
|
|
336
|
+
* siblings' refusals the moment the declaration names it.
|
|
337
|
+
* - **Holder flows.** For each key `k` in force, the sites it covers map to
|
|
338
|
+
* a set `T` of keys afterwards. `T = {k}` needs nothing; one other key is
|
|
339
|
+
* a MOVE; anything else is a SPLIT, which `k` survives when it is in `T`.
|
|
340
|
+
*
|
|
341
|
+
* `siteIds`, when given, are the sites the organization still owns: a site
|
|
342
|
+
* named by `before` that is gone neither receives a carry nor takes a copy of
|
|
343
|
+
* a record, because there is nothing left to hold either.
|
|
344
|
+
*/ export function planConsentGroupChange(before, after, options = {}) {
|
|
345
|
+
const owned = options.siteIds ? new Set(options.siteIds) : null;
|
|
346
|
+
const moving = owned ? withSitesOnly(before, owned) : before;
|
|
347
|
+
const beforeOf = groupOf(moving);
|
|
348
|
+
const afterOf = groupOf(after);
|
|
349
|
+
const keyBefore = (hostId)=>{
|
|
350
|
+
var _ref;
|
|
351
|
+
var _beforeOf_get;
|
|
352
|
+
return (_ref = (_beforeOf_get = beforeOf.get(hostId)) == null ? void 0 : _beforeOf_get.id) != null ? _ref : hostId;
|
|
353
|
+
};
|
|
354
|
+
const keyAfter = (hostId)=>{
|
|
355
|
+
var _ref;
|
|
356
|
+
var _afterOf_get;
|
|
357
|
+
return (_ref = (_afterOf_get = afterOf.get(hostId)) == null ? void 0 : _afterOf_get.id) != null ? _ref : hostId;
|
|
358
|
+
};
|
|
359
|
+
const membersBefore = (hostId)=>{
|
|
360
|
+
var _ref;
|
|
361
|
+
var _beforeOf_get;
|
|
362
|
+
return (_ref = (_beforeOf_get = beforeOf.get(hostId)) == null ? void 0 : _beforeOf_get.hostIds) != null ? _ref : [
|
|
363
|
+
hostId
|
|
364
|
+
];
|
|
365
|
+
};
|
|
366
|
+
const membersAfter = (hostId)=>{
|
|
367
|
+
var _ref;
|
|
368
|
+
var _afterOf_get;
|
|
369
|
+
return (_ref = (_afterOf_get = afterOf.get(hostId)) == null ? void 0 : _afterOf_get.hostIds) != null ? _ref : [
|
|
370
|
+
hostId
|
|
371
|
+
];
|
|
372
|
+
};
|
|
373
|
+
const sites = [
|
|
374
|
+
...new Set([
|
|
375
|
+
...Object.values(moving).flatMap((group)=>group.hostIds),
|
|
376
|
+
...Object.values(after).flatMap((group)=>group.hostIds)
|
|
377
|
+
])
|
|
378
|
+
].sort(byString);
|
|
379
|
+
const carries = [];
|
|
380
|
+
for (const a of sites){
|
|
381
|
+
const stays = new Set(membersAfter(a));
|
|
382
|
+
for (const b of membersBefore(a)){
|
|
383
|
+
if (b !== a && !stays.has(b)) carries.push({
|
|
384
|
+
toHostId: a,
|
|
385
|
+
fromHostId: b
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const flows = [];
|
|
390
|
+
const keys = [
|
|
391
|
+
...new Set(sites.map(keyBefore))
|
|
392
|
+
].sort(byString);
|
|
393
|
+
for (const key of keys){
|
|
394
|
+
const covered = sites.filter((hostId)=>keyBefore(hostId) === key);
|
|
395
|
+
const targets = [
|
|
396
|
+
...new Set(covered.map(keyAfter))
|
|
397
|
+
].sort(byString);
|
|
398
|
+
if (targets.length === 1 && targets[0] === key) continue;
|
|
399
|
+
if (targets.length === 1) {
|
|
400
|
+
flows.push({
|
|
401
|
+
kind: 'move',
|
|
402
|
+
from: key,
|
|
403
|
+
fromHostIds: covered,
|
|
404
|
+
to: targets[0],
|
|
405
|
+
toHostIds: [
|
|
406
|
+
...membersAfter(covered[0])
|
|
407
|
+
]
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
flows.push({
|
|
412
|
+
kind: 'split',
|
|
413
|
+
from: key,
|
|
414
|
+
fromHostIds: covered,
|
|
415
|
+
survives: targets.includes(key),
|
|
416
|
+
stayHostIds: covered.filter((hostId)=>keyAfter(hostId) === key),
|
|
417
|
+
successors: targets.filter((target)=>target !== key).map((target)=>({
|
|
418
|
+
key: target,
|
|
419
|
+
hostIds: covered.filter((hostId)=>keyAfter(hostId) === target)
|
|
420
|
+
}))
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const lines = planLines(before, after);
|
|
424
|
+
const touched = new Set();
|
|
425
|
+
const beforeAll = groupOf(before);
|
|
426
|
+
for (const hostId of new Set([
|
|
427
|
+
...beforeAll.keys(),
|
|
428
|
+
...afterOf.keys()
|
|
429
|
+
])){
|
|
430
|
+
var _ref, _ref1;
|
|
431
|
+
var _before_was_id, _after_now_id;
|
|
432
|
+
const was = beforeAll.get(hostId);
|
|
433
|
+
const now = afterOf.get(hostId);
|
|
434
|
+
const sameMembers = ((_ref = was == null ? void 0 : was.hostIds) != null ? _ref : [
|
|
435
|
+
hostId
|
|
436
|
+
]).join('\n') === ((_ref1 = now == null ? void 0 : now.hostIds) != null ? _ref1 : [
|
|
437
|
+
hostId
|
|
438
|
+
]).join('\n');
|
|
439
|
+
const sameName = !was || !now || ((_before_was_id = before[was.id]) == null ? void 0 : _before_was_id.name) === ((_after_now_id = after[now.id]) == null ? void 0 : _after_now_id.name);
|
|
440
|
+
if ((was == null ? void 0 : was.id) !== (now == null ? void 0 : now.id) || !sameMembers || !sameName) touched.add(hostId);
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
before,
|
|
444
|
+
after,
|
|
445
|
+
carries,
|
|
446
|
+
flows,
|
|
447
|
+
lines,
|
|
448
|
+
hostIds: [
|
|
449
|
+
...touched
|
|
450
|
+
].sort(byString),
|
|
451
|
+
renameOnly: !carries.length && !flows.length && lines.length > 0 && lines.every((line)=>line.kind === 'renamed')
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** The declaration-level changes, in the order the activity log lists them. */ function planLines(before, after) {
|
|
455
|
+
const beforeOf = groupOf(before);
|
|
456
|
+
const afterOf = groupOf(after);
|
|
457
|
+
const created = [];
|
|
458
|
+
const renamed = [];
|
|
459
|
+
const added = [];
|
|
460
|
+
const removed = [];
|
|
461
|
+
const moved = [];
|
|
462
|
+
const dissolved = [];
|
|
463
|
+
const moveInto = (hostId, toGroupId)=>{
|
|
464
|
+
const from = beforeOf.get(hostId);
|
|
465
|
+
if (!from || from.id === toGroupId) return false;
|
|
466
|
+
moved.push({
|
|
467
|
+
kind: 'moved',
|
|
468
|
+
hostId,
|
|
469
|
+
fromGroupId: from.id,
|
|
470
|
+
fromName: before[from.id].name,
|
|
471
|
+
toGroupId,
|
|
472
|
+
toName: after[toGroupId].name
|
|
473
|
+
});
|
|
474
|
+
return true;
|
|
475
|
+
};
|
|
476
|
+
for (const id of Object.keys(after).sort(byString)){
|
|
477
|
+
const next = after[id];
|
|
478
|
+
const previous = before[id];
|
|
479
|
+
if (!previous) {
|
|
480
|
+
created.push({
|
|
481
|
+
kind: 'created',
|
|
482
|
+
groupId: id,
|
|
483
|
+
name: next.name,
|
|
484
|
+
hostIds: [
|
|
485
|
+
...next.hostIds
|
|
486
|
+
]
|
|
487
|
+
});
|
|
488
|
+
for (const hostId of next.hostIds)moveInto(hostId, id);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (previous.name !== next.name) {
|
|
492
|
+
renamed.push({
|
|
493
|
+
kind: 'renamed',
|
|
494
|
+
groupId: id,
|
|
495
|
+
from: previous.name,
|
|
496
|
+
to: next.name
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
for (const hostId of next.hostIds){
|
|
500
|
+
if (previous.hostIds.includes(hostId)) continue;
|
|
501
|
+
if (!moveInto(hostId, id)) {
|
|
502
|
+
added.push({
|
|
503
|
+
kind: 'added',
|
|
504
|
+
groupId: id,
|
|
505
|
+
name: next.name,
|
|
506
|
+
hostId
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const hostId of previous.hostIds){
|
|
511
|
+
if (next.hostIds.includes(hostId)) continue;
|
|
512
|
+
// A site that went to another group is that group's `moved` line.
|
|
513
|
+
if (!afterOf.has(hostId)) {
|
|
514
|
+
removed.push({
|
|
515
|
+
kind: 'removed',
|
|
516
|
+
groupId: id,
|
|
517
|
+
name: next.name,
|
|
518
|
+
hostId
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
for (const id of Object.keys(before).sort(byString)){
|
|
524
|
+
if (!(id in after)) dissolved.push({
|
|
525
|
+
kind: 'dissolved',
|
|
526
|
+
groupId: id,
|
|
527
|
+
name: before[id].name
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return [
|
|
531
|
+
...created,
|
|
532
|
+
...renamed,
|
|
533
|
+
...added,
|
|
534
|
+
...removed,
|
|
535
|
+
...moved,
|
|
536
|
+
...dissolved
|
|
537
|
+
];
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* The sites in a sentence: "A", "A and B", "A, B and C".
|
|
541
|
+
*/ export function listSiteNames(names) {
|
|
542
|
+
var _names_;
|
|
543
|
+
if (names.length <= 1) return (_names_ = names[0]) != null ? _names_ : '';
|
|
544
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* One line of the plan as the organization's activity log says it.
|
|
548
|
+
*
|
|
549
|
+
* `siteName` turns a site id into what the console shows for it; the id
|
|
550
|
+
* itself is the fallback, so a line is never blank.
|
|
551
|
+
*/ export function describeConsentGroupChangeLine(line, siteName = (hostId)=>hostId) {
|
|
552
|
+
const site = (hostId)=>siteName(hostId) || hostId;
|
|
553
|
+
switch(line.kind){
|
|
554
|
+
case 'created':
|
|
555
|
+
return `Created consent group "${line.name}" with ${listSiteNames(line.hostIds.map(site))}`;
|
|
556
|
+
case 'renamed':
|
|
557
|
+
return `Renamed consent group "${line.from}" to "${line.to}"`;
|
|
558
|
+
case 'added':
|
|
559
|
+
return `Added ${site(line.hostId)} to consent group "${line.name}"`;
|
|
560
|
+
case 'removed':
|
|
561
|
+
return `Removed ${site(line.hostId)} from consent group "${line.name}"`;
|
|
562
|
+
case 'moved':
|
|
563
|
+
return `Moved ${site(line.hostId)} from consent group "${line.fromName}" to "${line.toName}"`;
|
|
564
|
+
case 'dissolved':
|
|
565
|
+
return `Dissolved consent group "${line.name}"`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const PHASES = [
|
|
569
|
+
'carry',
|
|
570
|
+
'rehome',
|
|
571
|
+
'sweep'
|
|
572
|
+
];
|
|
573
|
+
/** The marker off an org document, or `null` when no change is in flight. */ export function readConsentGroupsChange(org) {
|
|
574
|
+
const raw = (org != null ? org : {})[CONSENT_GROUPS_CHANGE_FIELD];
|
|
575
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
576
|
+
const value = raw;
|
|
577
|
+
const changeId = typeof value['changeId'] === 'string' ? value['changeId'] : '';
|
|
578
|
+
const phase = PHASES.find((candidate)=>candidate === value['phase']);
|
|
579
|
+
if (!changeId || !phase) return null;
|
|
580
|
+
const declaredAtMs = Number(value['declaredAtMs']);
|
|
581
|
+
return _extends({
|
|
582
|
+
changeId,
|
|
583
|
+
phase,
|
|
584
|
+
hostIds: Array.isArray(value['hostIds']) ? value['hostIds'].filter((id)=>typeof id === 'string' && id !== '') : [],
|
|
585
|
+
startedAtMs: Number(value['startedAtMs']) || 0
|
|
586
|
+
}, Number.isFinite(declaredAtMs) && declaredAtMs > 0 ? {
|
|
587
|
+
declaredAtMs
|
|
588
|
+
} : {});
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Whether the holder records `hostId` reads may be mid-move: a change in
|
|
592
|
+
* flight has flipped the declaration and names this site.
|
|
593
|
+
*
|
|
594
|
+
* A reader that decides something by a record's ABSENCE — a dynamic list
|
|
595
|
+
* that removes whoever no longer matches — must not decide it while this
|
|
596
|
+
* holds, because a record that has not moved yet reads as missing.
|
|
597
|
+
*/ export function consentGroupHoldersInMotion(org, hostId) {
|
|
598
|
+
const marker = readConsentGroupsChange(org);
|
|
599
|
+
return Boolean(marker && marker.phase !== 'carry' && marker.hostIds.includes(hostId));
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The executor's steps, in order.
|
|
603
|
+
*
|
|
604
|
+
* - `carry` — every carry, then every participant's carry phase.
|
|
605
|
+
* - `catch-up-pre` — the refusals filed since the carry began.
|
|
606
|
+
* - `declare` — the flip, in one transaction.
|
|
607
|
+
* - `catch-up-post` — the same catch-up, for what raced the flip.
|
|
608
|
+
* - `rehome` — every participant's holder records.
|
|
609
|
+
* - `sweep-wait` — until {@link CONSENT_GROUP_SWEEP_DELAY_MS} has passed.
|
|
610
|
+
* - `sweep` — every carry again in full, and every participant's sweep.
|
|
611
|
+
* - `finish` — the marker cleared.
|
|
612
|
+
*
|
|
613
|
+
* A rename-only change runs `declare` and `finish` alone.
|
|
614
|
+
*/ export const CONSENT_GROUP_CHANGE_STEPS = [
|
|
615
|
+
'carry',
|
|
616
|
+
'catch-up-pre',
|
|
617
|
+
'declare',
|
|
618
|
+
'catch-up-post',
|
|
619
|
+
'rehome',
|
|
620
|
+
'sweep-wait',
|
|
621
|
+
'sweep',
|
|
622
|
+
'finish'
|
|
623
|
+
];
|
|
624
|
+
/** The steps a rename-only change runs. */ export const CONSENT_GROUP_RENAME_STEPS = [
|
|
625
|
+
'declare',
|
|
626
|
+
'finish'
|
|
627
|
+
];
|
|
628
|
+
/** The marker phase a step runs under. */ export function consentGroupChangePhaseOf(step) {
|
|
629
|
+
if (step === 'carry' || step === 'catch-up-pre' || step === 'declare') return 'carry';
|
|
630
|
+
if (step === 'catch-up-post' || step === 'rehome') return 'rehome';
|
|
631
|
+
return 'sweep';
|
|
632
|
+
}
|
|
633
|
+
/** The work bands behind {@link ConsentGroupChangeEstimate}. */ export function estimateConsentGroupChange(plan, documents) {
|
|
634
|
+
if (plan.renameOnly) return 'instant';
|
|
635
|
+
if (documents < 5000) return 'under-a-minute';
|
|
636
|
+
if (documents < 100000) return 'minutes';
|
|
637
|
+
return 'long';
|
|
638
|
+
}
|
|
639
|
+
/** The groups whose disclosure sentence differs between the declarations. */ export function consentGroupDisclosureChanges(before, after) {
|
|
640
|
+
const sentence = (id, group)=>group ? consentGroupDisclosure({
|
|
641
|
+
hostId: group.hostIds[0],
|
|
642
|
+
groupId: id,
|
|
643
|
+
name: group.name,
|
|
644
|
+
hostIds: group.hostIds,
|
|
645
|
+
declared: true,
|
|
646
|
+
awaitsConfirmation: false
|
|
647
|
+
}) : null;
|
|
648
|
+
const out = [];
|
|
649
|
+
for (const id of [
|
|
650
|
+
...new Set([
|
|
651
|
+
...Object.keys(before),
|
|
652
|
+
...Object.keys(after)
|
|
653
|
+
])
|
|
654
|
+
].sort(byString)){
|
|
655
|
+
var _after_id;
|
|
656
|
+
const was = sentence(id, before[id]);
|
|
657
|
+
const now = sentence(id, after[id]);
|
|
658
|
+
if (was === now) continue;
|
|
659
|
+
out.push({
|
|
660
|
+
groupId: id,
|
|
661
|
+
hostIds: [
|
|
662
|
+
...((_after_id = after[id]) != null ? _after_id : before[id]).hostIds
|
|
663
|
+
],
|
|
664
|
+
before: was,
|
|
665
|
+
after: now
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
//# sourceMappingURL=consent-group-change.js.map
|