@aglyn/aglyn 1.0.0-beta.196 → 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.
Files changed (26) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/consent-group-change.d.ts +519 -0
  3. package/src/lib/app-utils/consent-group-change.js +671 -0
  4. package/src/lib/app-utils/consent-group-change.js.map +1 -0
  5. package/src/lib/app-utils/consent-groups.d.ts +106 -8
  6. package/src/lib/app-utils/consent-groups.js +163 -8
  7. package/src/lib/app-utils/consent-groups.js.map +1 -1
  8. package/src/lib/app-utils/contact-merge.js +37 -4
  9. package/src/lib/app-utils/contact-merge.js.map +1 -1
  10. package/src/lib/app-utils/docs-help.generated.d.ts +7 -7
  11. package/src/lib/app-utils/docs-help.generated.js +13 -0
  12. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  13. package/src/lib/app-utils/docs-index.generated.js +48 -34
  14. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  15. package/src/lib/app-utils/health-report.js +13 -0
  16. package/src/lib/app-utils/health-report.js.map +1 -1
  17. package/src/lib/app-utils/marketing-consent.d.ts +59 -0
  18. package/src/lib/app-utils/marketing-consent.js +86 -0
  19. package/src/lib/app-utils/marketing-consent.js.map +1 -1
  20. package/src/lib/foundation/definitions/org-billing.types.d.ts +22 -3
  21. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  22. package/src/lib/plugin-manager/plugin-consent-group-change.d.ts +116 -0
  23. package/src/lib/plugin-manager/plugin-consent-group-change.js +113 -0
  24. package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -0
  25. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  26. 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