@aglyn/tenant-data-admin 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.
@@ -0,0 +1,1096 @@
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
+ * RUNNING A CONSENT GROUP CHANGE (AGL-3320) — the executor.
19
+ *
20
+ * `consent-group-change.ts` in `app-utils` decides WHAT a change requires;
21
+ * this carries it out, across as many invocations as it takes, without ever
22
+ * leaving a refusal unhonored in between.
23
+ *
24
+ * ## The order is the safety argument
25
+ *
26
+ * 1. START — one transaction on the org document: the stored declaration is
27
+ * the one the editor rendered, and no other change is in flight. It sets
28
+ * the marker `consentGroupsChange` and creates the job document.
29
+ * 2. CARRY — every carry of the plan through the three platform stores
30
+ * (`consent-group-carry.ts`), then every participant's carry phase.
31
+ * 3. CATCH UP — everything filed since the carry began, again.
32
+ * 4. DECLARE — one transaction: the marker still names this change in its
33
+ * carry phase and the stored declaration is still the one the job
34
+ * started from. Only then is the new declaration written.
35
+ * 5. CATCH UP — once more, for whatever raced the flip.
36
+ * 6. RE-HOME — every participant's holder records.
37
+ * 7. SWEEP — once the sweep delay has passed, the whole carry and every
38
+ * participant's sweep, idempotently.
39
+ * 8. DONE — the marker is cleared.
40
+ *
41
+ * The flip cannot happen before every carry and both catch-up passes are
42
+ * done, because the steps run strictly in order and `declare` is only ever
43
+ * reached from a finished `catch-up-pre`. Until the flip the declaration in
44
+ * force is the old one, so a change that stalls or is canceled before it
45
+ * has changed nothing anybody can observe — except refusals honored on more
46
+ * sites, which is the safe direction. A rename moves nothing, and runs the
47
+ * declare and finish alone.
48
+ *
49
+ * ## One invocation at a time, and any invocation may finish it
50
+ *
51
+ * The console's progress panel calls `continue` while it is open, and the
52
+ * cron backstop calls every fifteen minutes. Each claims a LEASE on the job
53
+ * by transaction and renews it on every unit it saves; a lease that lapses
54
+ * (a process that died) is simply claimed by the next caller. A unit is one
55
+ * page of one store, or one participant call, and every unit is idempotent,
56
+ * so a unit re-run after a crash writes nothing it already wrote.
57
+ *
58
+ * ## A failure is retried, and five in a row are shown
59
+ *
60
+ * A unit that throws releases the lease and counts a failure against the job;
61
+ * the next invocation retries it from its cursor. Five consecutive failures
62
+ * mark the job STALLED — the activity log says so once, and the panel shows
63
+ * the error with a retry — and the cron keeps trying. A participant's throw
64
+ * is one of these: the change never flips past a participant that could not
65
+ * carry its refusals (see `plugin-consent-group-change.ts`).
66
+ */ import { CONSENT_GROUP_CHANGE_STEPS, CONSENT_GROUP_CHANGES_COLLECTION, CONSENT_GROUP_RENAME_STEPS, CONSENT_GROUP_SWEEP_DELAY_MS, CONSENT_GROUPS_CHANGE_FIELD, consentGroupChangePhaseOf, consentGroupDisclosureChanges, consentGroupsFingerprint, describeConsentGroupChangeLine, discardedConsentGroupIds, estimateConsentGroupChange, orgSiteIds, planConsentGroupChange, readConsentGroupsChange, validateConsentGroupDeclaration } from "@aglyn/aglyn/app-utils/consent-group-change";
67
+ import { CONSENT_GROUPS_FIELD, consentGroupsAwaitConfirmation, readConsentGroups } from "@aglyn/aglyn/app-utils/consent-groups";
68
+ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
69
+ import { DEFAULT_EMAIL_TOPICS, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
70
+ import { resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
71
+ import { isOrgWideMember } from "@aglyn/aglyn/app-utils/organizations";
72
+ import { listPluginConsentGroupParticipants, previewPluginConsentGroupChange } from "@aglyn/aglyn/plugin-manager/plugin-consent-group-change";
73
+ import { FieldValue } from "firebase-admin/firestore";
74
+ import { CONSENT_GROUP_CARRY_STORES, CONSENT_GROUP_CARRY_SUBCOLLECTIONS, carryConsentGroupRefusals } from "./consent-group-carry.js";
75
+ import firebaseAdmin from "./firebase-admin.js";
76
+ import { getLockdownVerdict } from "./lockdown.js";
77
+ import { logOrgActivity } from "./organizations.js";
78
+ /** How long a claimed job stays claimed without a renewal. */ export const CONSENT_GROUP_CHANGE_LEASE_MS = 90000;
79
+ /** Consecutive failed units before a job is shown as stalled. */ export const CONSENT_GROUP_CHANGE_STALL_AFTER = 5;
80
+ /**
81
+ * How far before the carry began a catch-up reads. A refusal filed in the
82
+ * seconds the carry's first page was being read, with a clock a little
83
+ * behind, is inside it.
84
+ */ const CATCH_UP_OVERLAP_MS = 5 * 60000;
85
+ /** Left before a deadline for the last save. */ const DEADLINE_SAFETY_MS = 5000;
86
+ /** The phases the cron looks for a change in. */ const RUNNING_PHASES = [
87
+ 'carry',
88
+ 'rehome',
89
+ 'sweep'
90
+ ];
91
+ function contextOf(input) {
92
+ var _input_firestore, _input_log, _input_now, _input_lockedOut;
93
+ return {
94
+ firestore: (_input_firestore = input.firestore) != null ? _input_firestore : firebaseAdmin.app().firestore(),
95
+ log: (_input_log = input.log) != null ? _input_log : logOrgActivity,
96
+ now: (_input_now = input.now) != null ? _input_now : Date.now,
97
+ lockedOut: (_input_lockedOut = input.lockedOut) != null ? _input_lockedOut : async (org, uid)=>await getLockdownVerdict({
98
+ org: org,
99
+ uid,
100
+ intent: 'write'
101
+ }) !== null
102
+ };
103
+ }
104
+ const NOT_FOUND = {
105
+ ok: false,
106
+ status: 404,
107
+ body: {
108
+ error: 'No such consent group change'
109
+ }
110
+ };
111
+ /** A caller whose lease was taken, or whose job was canceled, under it. */ let LeaseLost = class LeaseLost extends Error {
112
+ };
113
+ const orgDoc = (ctx, orgId)=>ctx.firestore.collection('orgs').doc(orgId);
114
+ const jobDoc = (ctx, orgId, changeId)=>orgDoc(ctx, orgId).collection(CONSENT_GROUP_CHANGES_COLLECTION).doc(changeId);
115
+ /** The job as the plan the participants and the carries read. */ function planOf(job) {
116
+ return _extends({}, job.plan, {
117
+ before: job.before,
118
+ after: job.after
119
+ });
120
+ }
121
+ /** An error as the job records it: bounded, and with no address in it. */ function sanitizeError(error) {
122
+ const message = error instanceof Error ? error.message : String(error != null ? error : 'Unknown error');
123
+ return message.replace(/[^\s@]+@[^\s@]+/g, '[address]').slice(0, 300) || 'Unknown error';
124
+ }
125
+ export function consentGroupChangeStatus(job, nowMs = Date.now()) {
126
+ var _job_declaredAtMs, _job_failures, _job_lastError, _ref, _job_plugins;
127
+ var _job_sitesReceiving;
128
+ const declaredAtMs = (_job_declaredAtMs = job.declaredAtMs) != null ? _job_declaredAtMs : null;
129
+ const index = job.steps.indexOf(job.step);
130
+ return {
131
+ changeId: job.changeId,
132
+ phase: job.phase,
133
+ done: job.status !== 'running',
134
+ progress: {
135
+ status: job.status,
136
+ step: job.step,
137
+ stepIndex: index >= 0 ? index + 1 : job.steps.length,
138
+ stepCount: job.steps.length,
139
+ declaredAtMs,
140
+ sweepAtMs: declaredAtMs !== null && !job.plan.renameOnly ? declaredAtMs + CONSENT_GROUP_SWEEP_DELAY_MS : null,
141
+ stalled: job.stalled === true,
142
+ failures: (_job_failures = job.failures) != null ? _job_failures : 0,
143
+ lastError: (_job_lastError = job.lastError) != null ? _job_lastError : null,
144
+ leaseUntilMs: job.lease && job.lease.untilMs > nowMs ? job.lease.untilMs : null,
145
+ counts: _extends({}, job.counts),
146
+ sitesReceiving: (_ref = (_job_sitesReceiving = job.sitesReceiving) == null ? void 0 : _job_sitesReceiving.length) != null ? _ref : 0,
147
+ plugins: (_job_plugins = job.plugins) != null ? _job_plugins : {}
148
+ }
149
+ };
150
+ }
151
+ function prepare(org, expected, groups) {
152
+ const marker = readConsentGroupsChange(org);
153
+ if (marker) {
154
+ return {
155
+ ok: false,
156
+ status: 409,
157
+ body: {
158
+ error: 'A consent group change is still finishing',
159
+ changeId: marker.changeId,
160
+ phase: marker.phase
161
+ }
162
+ };
163
+ }
164
+ const raw = org[CONSENT_GROUPS_FIELD];
165
+ if (consentGroupsFingerprint(raw) !== consentGroupsFingerprint(expected)) {
166
+ return {
167
+ ok: false,
168
+ status: 409,
169
+ body: {
170
+ error: 'Someone else changed consent groups while you were editing',
171
+ current: raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null
172
+ }
173
+ };
174
+ }
175
+ const before = readConsentGroups(org);
176
+ const siteIds = orgSiteIds(org);
177
+ const validation = validateConsentGroupDeclaration({
178
+ groups,
179
+ before,
180
+ siteIds,
181
+ reserved: raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw) : []
182
+ });
183
+ if (!validation.ok) {
184
+ return {
185
+ ok: false,
186
+ status: 400,
187
+ body: {
188
+ error: 'Those consent groups cannot be saved',
189
+ errors: validation.errors
190
+ }
191
+ };
192
+ }
193
+ const after = validation.after;
194
+ return {
195
+ org,
196
+ before,
197
+ after,
198
+ plan: planConsentGroupChange(before, after, {
199
+ siteIds
200
+ }),
201
+ siteIds
202
+ };
203
+ }
204
+ const isRefusal = (value)=>Boolean(value && typeof value === 'object' && value.ok === false);
205
+ /** Every site a plan names, for the names its lines are written with. */ function plannedSites(plan) {
206
+ return [
207
+ ...new Set([
208
+ ...plan.hostIds,
209
+ ...Object.values(plan.before).flatMap((group)=>group.hostIds),
210
+ ...Object.values(plan.after).flatMap((group)=>group.hostIds)
211
+ ])
212
+ ];
213
+ }
214
+ /** What the console calls each site: its name, its subdomain, or its id. */ async function siteNamesFor(ctx, hostIds) {
215
+ if (!hostIds.length) return {};
216
+ const hosts = ctx.firestore.collection('hosts');
217
+ const snapshots = await ctx.firestore.getAll(...hostIds.map((id)=>hosts.doc(id)));
218
+ const names = {};
219
+ snapshots.forEach((snapshot, index)=>{
220
+ const id = hostIds[index];
221
+ names[id] = String(snapshot.get('name') || snapshot.get('subdomain') || id);
222
+ });
223
+ return names;
224
+ }
225
+ const describeLines = (lines, names)=>lines.map((line)=>describeConsentGroupChangeLine(line, (hostId)=>{
226
+ var _names_hostId;
227
+ return (_names_hostId = names[hostId]) != null ? _names_hostId : hostId;
228
+ }));
229
+ /*==========================================
230
+ * THE PREVIEW
231
+ *=========================================*/ async function countOf(query) {
232
+ const snapshot = await query.count().get();
233
+ return Number(snapshot.data().count) || 0;
234
+ }
235
+ /**
236
+ * What a change would do, counted and worded, writing nothing.
237
+ *
238
+ * Refused exactly as `apply` would be — a change in flight, a stale
239
+ * `expected`, an invalid declaration — so the review step never shows a
240
+ * change the next click cannot make.
241
+ */ export async function previewConsentGroupChange(input) {
242
+ var _input_org;
243
+ const ctx = contextOf(input);
244
+ const org = (_input_org = input.org) != null ? _input_org : (await orgDoc(ctx, input.orgId).get()).data();
245
+ if (!org) return {
246
+ ok: false,
247
+ status: 404,
248
+ body: {
249
+ error: 'No such workspace'
250
+ }
251
+ };
252
+ const prepared = prepare(org, input.expected, input.groups);
253
+ if (isRefusal(prepared)) return prepared;
254
+ const { plan, before, after } = prepared;
255
+ const db = ctx.firestore;
256
+ const cache = new Map();
257
+ const cached = (key, query)=>{
258
+ let pending = cache.get(key);
259
+ if (!pending) {
260
+ pending = countOf(query());
261
+ cache.set(key, pending);
262
+ }
263
+ return pending;
264
+ };
265
+ const store = (hostId, name)=>db.collection('hosts').doc(hostId).collection(CONSENT_GROUP_CARRY_SUBCOLLECTIONS[name]);
266
+ const suppressions = (hostId)=>cached(`s:${hostId}`, ()=>store(hostId, 'siteSuppressions'));
267
+ const optOuts = (hostId)=>cached(`t:${hostId}`, ()=>store(hostId, 'topicOptOuts'));
268
+ const paces = (hostId)=>cached(`p:${hostId}`, ()=>store(hostId, 'paces').where('cadence', '!=', null));
269
+ const carries = await Promise.all(plan.carries.map(async (carry)=>({
270
+ toHostId: carry.toHostId,
271
+ fromHostId: carry.fromHostId,
272
+ siteSuppressions: await suppressions(carry.fromHostId),
273
+ topicOptOuts: await optOuts(carry.fromHostId),
274
+ paces: await paces(carry.fromHostId)
275
+ })));
276
+ const membersIn = (declaration, hostId)=>{
277
+ var _ref;
278
+ var _Object_values_find;
279
+ return (_ref = (_Object_values_find = Object.values(declaration).find((group)=>group.hostIds.includes(hostId))) == null ? void 0 : _Object_values_find.hostIds) != null ? _ref : [
280
+ hostId
281
+ ];
282
+ };
283
+ const joining = [];
284
+ for (const hostId of [
285
+ ...new Set(Object.values(after).flatMap((group)=>group.hostIds))
286
+ ].sort()){
287
+ const had = new Set(membersIn(before, hostId));
288
+ const siblings = membersIn(after, hostId).filter((id)=>id !== hostId && !had.has(id));
289
+ if (siblings.length) joining.push({
290
+ hostId,
291
+ siblings
292
+ });
293
+ }
294
+ const inherited = await Promise.all(joining.map(async ({ hostId, siblings })=>{
295
+ let refusals = 0;
296
+ for (const sibling of siblings){
297
+ refusals += await suppressions(sibling) + await optOuts(sibling);
298
+ }
299
+ return {
300
+ hostId,
301
+ refusals
302
+ };
303
+ }));
304
+ /*
305
+ * A pending confirmation holds a sibling's mail only with the switch on,
306
+ * so only then does a separation release anything. Counted over the
307
+ * built-in streams: the catalog of an organization's own streams belongs to
308
+ * the Email plugin, and a stream it added is not counted here.
309
+ */ const pendingHolds = [];
310
+ if (consentGroupsAwaitConfirmation(org)) {
311
+ const released = new Map();
312
+ for (const carry of plan.carries){
313
+ var _released_get;
314
+ released.set(carry.toHostId, [
315
+ ...(_released_get = released.get(carry.toHostId)) != null ? _released_get : [],
316
+ carry.fromHostId
317
+ ]);
318
+ }
319
+ for (const [hostId, releasedHostIds] of [
320
+ ...released.entries()
321
+ ].sort()){
322
+ for (const topic of DEFAULT_EMAIL_TOPICS){
323
+ const count = await countOf(db.collection('hosts').doc(hostId).collection(TOPIC_OPT_OUTS_SUBCOLLECTION).where(`topics.${topic.id}.confirmedAt`, '==', null));
324
+ if (count > 0) pendingHolds.push({
325
+ hostId,
326
+ topicId: topic.id,
327
+ count,
328
+ releasedHostIds
329
+ });
330
+ }
331
+ }
332
+ }
333
+ const changedGroups = Object.entries(after).filter(([id, group])=>{
334
+ const was = before[id];
335
+ return !was || was.hostIds.join('\n') !== group.hostIds.join('\n');
336
+ }).map(([, group])=>group.hostIds);
337
+ let partialAccessMembers = 0;
338
+ if (changedGroups.length) {
339
+ const members = await orgDoc(ctx, input.orgId).collection('members').get();
340
+ for (const member of members.docs){
341
+ var _data_hostAccess;
342
+ const data = member.data();
343
+ if (isOrgWideMember(data)) continue;
344
+ const reach = new Set(Object.keys((_data_hostAccess = data['hostAccess']) != null ? _data_hostAccess : {}));
345
+ if (changedGroups.some((hostIds)=>hostIds.some((id)=>reach.has(id)) && !hostIds.every((id)=>reach.has(id)))) {
346
+ partialAccessMembers += 1;
347
+ }
348
+ }
349
+ }
350
+ const policy = resolveMarketingConsentPolicy(org['marketingConsentPolicy']);
351
+ const forwardPolicyWarning = policy.mode === 'forward' && joining.length ? {
352
+ hostIds: joining.map((entry)=>entry.hostId),
353
+ enforceFromMs: policy.enforceFromMs
354
+ } : null;
355
+ const participants = await previewPluginConsentGroupChange({
356
+ orgId: input.orgId,
357
+ plan
358
+ });
359
+ const names = await siteNamesFor(ctx, plannedSites(plan));
360
+ const texts = describeLines(plan.lines, names);
361
+ const documents = carries.reduce((total, carry)=>total + carry.siteSuppressions + carry.topicOptOuts + carry.paces, 0) + participants.reduce((total, entry)=>{
362
+ var _entry_lines;
363
+ return total + ((_entry_lines = entry.lines) != null ? _entry_lines : []).reduce((sum, line)=>{
364
+ var _line_count;
365
+ return sum + ((_line_count = line.count) != null ? _line_count : 0);
366
+ }, 0);
367
+ }, 0);
368
+ return {
369
+ ok: true,
370
+ preview: {
371
+ before,
372
+ after,
373
+ discarded: discardedConsentGroupIds(org),
374
+ lines: plan.lines.map((line, index)=>_extends({}, line, {
375
+ text: texts[index]
376
+ })),
377
+ disclosures: consentGroupDisclosureChanges(before, after),
378
+ carries,
379
+ inherited,
380
+ pendingHolds,
381
+ partialAccessMembers,
382
+ forwardPolicyWarning,
383
+ participants,
384
+ capturesDisclosing: [
385
+ 'form'
386
+ ],
387
+ estimate: estimateConsentGroupChange(plan, documents)
388
+ }
389
+ };
390
+ }
391
+ /*==========================================
392
+ * STARTING
393
+ *=========================================*/ /**
394
+ * Starts a change: the org marker and the job, in one transaction that
395
+ * refuses a change in flight, a stale `expected` and an invalid declaration.
396
+ * Writes nothing else — the caller advances it.
397
+ */ export async function startConsentGroupChange(input) {
398
+ const ctx = contextOf(input);
399
+ const orgRef = orgDoc(ctx, input.orgId);
400
+ const snapshot = await orgRef.get();
401
+ if (!snapshot.exists) return {
402
+ ok: false,
403
+ status: 404,
404
+ body: {
405
+ error: 'No such workspace'
406
+ }
407
+ };
408
+ const outside = prepare(snapshot.data(), input.expected, input.groups);
409
+ if (isRefusal(outside)) return outside;
410
+ // Names are words for the log, read once and outside the transaction.
411
+ const names = await siteNamesFor(ctx, plannedSites(outside.plan));
412
+ const participants = listPluginConsentGroupParticipants().map((entry)=>entry.pluginId);
413
+ const changeId = createResourceUid();
414
+ const jobRef = jobDoc(ctx, input.orgId, changeId);
415
+ const nowMs = ctx.now();
416
+ const started = await ctx.firestore.runTransaction(async (transaction)=>{
417
+ var _input_actor_uid, _input_actor_email;
418
+ const current = await transaction.get(orgRef);
419
+ if (!current.exists) {
420
+ return {
421
+ ok: false,
422
+ status: 404,
423
+ body: {
424
+ error: 'No such workspace'
425
+ }
426
+ };
427
+ }
428
+ const prepared = prepare(current.data(), input.expected, input.groups);
429
+ if (isRefusal(prepared)) return prepared;
430
+ const { plan, before, after, org } = prepared;
431
+ const steps = plan.renameOnly ? [
432
+ ...CONSENT_GROUP_RENAME_STEPS
433
+ ] : [
434
+ ...CONSENT_GROUP_CHANGE_STEPS
435
+ ];
436
+ const job = {
437
+ changeId,
438
+ orgId: input.orgId,
439
+ status: 'running',
440
+ step: steps[0],
441
+ phase: 'carry',
442
+ steps,
443
+ beforeFingerprint: consentGroupsFingerprint(org[CONSENT_GROUPS_FIELD]),
444
+ before,
445
+ after,
446
+ plan: {
447
+ carries: plan.carries,
448
+ flows: plan.flows,
449
+ lines: plan.lines,
450
+ hostIds: plan.hostIds,
451
+ renameOnly: plan.renameOnly
452
+ },
453
+ lines: describeLines(plan.lines, names),
454
+ siteNames: names,
455
+ participants: plan.renameOnly ? [] : participants,
456
+ units: {},
457
+ counts: {
458
+ siteSuppressions: 0,
459
+ topicOptOuts: 0,
460
+ paces: 0
461
+ },
462
+ sitesReceiving: [],
463
+ plugins: {},
464
+ lease: null,
465
+ failures: 0,
466
+ stalled: false,
467
+ lastError: null,
468
+ actor: {
469
+ uid: (_input_actor_uid = input.actor.uid) != null ? _input_actor_uid : null,
470
+ email: (_input_actor_email = input.actor.email) != null ? _input_actor_email : null
471
+ },
472
+ startedAtMs: nowMs,
473
+ carryStartedAtMs: nowMs,
474
+ declaredAtMs: null,
475
+ finishedAtMs: null,
476
+ canceledAtMs: null
477
+ };
478
+ const marker = {
479
+ changeId,
480
+ phase: 'carry',
481
+ hostIds: plan.hostIds,
482
+ startedAtMs: nowMs
483
+ };
484
+ transaction.update(orgRef, {
485
+ [CONSENT_GROUPS_CHANGE_FIELD]: marker,
486
+ updatedAt: FieldValue.serverTimestamp()
487
+ });
488
+ transaction.create(jobRef, _extends({}, job, {
489
+ createdAt: FieldValue.serverTimestamp(),
490
+ updatedAt: FieldValue.serverTimestamp()
491
+ }));
492
+ return {
493
+ ok: true,
494
+ job
495
+ };
496
+ });
497
+ if (isRefusal(started)) return started;
498
+ const { job } = started;
499
+ await ctx.log(input.orgId, {
500
+ uid: job.actor.uid,
501
+ email: job.actor.email
502
+ }, `Started a consent group change: ${job.lines.join('; ')}`, {
503
+ type: 'org',
504
+ id: input.orgId
505
+ });
506
+ return {
507
+ ok: true,
508
+ changeId,
509
+ job
510
+ };
511
+ }
512
+ /** The current step's units, in the order they run. */ function unitsOf(job) {
513
+ const carries = (sinceMs)=>job.plan.carries.flatMap((_carry, carryIndex)=>CONSENT_GROUP_CARRY_STORES.map((store)=>({
514
+ kind: 'carry',
515
+ key: `c${carryIndex}:${store}`,
516
+ carryIndex,
517
+ store,
518
+ sinceMs
519
+ })));
520
+ const participants = (phase)=>job.participants.map((pluginId)=>({
521
+ kind: 'participant',
522
+ key: `p:${pluginId}`,
523
+ pluginId,
524
+ phase
525
+ }));
526
+ const since = job.carryStartedAtMs - CATCH_UP_OVERLAP_MS;
527
+ switch(job.step){
528
+ case 'carry':
529
+ return [
530
+ ...carries(null),
531
+ ...participants('carry')
532
+ ];
533
+ case 'catch-up-pre':
534
+ case 'catch-up-post':
535
+ return [
536
+ ...carries(since),
537
+ ...participants('carry')
538
+ ];
539
+ case 'rehome':
540
+ return participants('rehome');
541
+ case 'sweep':
542
+ return [
543
+ ...carries(null),
544
+ ...participants('sweep')
545
+ ];
546
+ default:
547
+ return [];
548
+ }
549
+ }
550
+ function jobFrom(snapshot) {
551
+ return snapshot.exists ? snapshot.data() : null;
552
+ }
553
+ /**
554
+ * Saves a unit's result, renewing the lease — only while the job is still
555
+ * running and still this caller's.
556
+ */ async function save(ctx, jobRef, owner, fields, extra) {
557
+ return ctx.firestore.runTransaction(async (transaction)=>{
558
+ var _job_lease;
559
+ const job = jobFrom(await transaction.get(jobRef));
560
+ if (!job || job.status !== 'running' || ((_job_lease = job.lease) == null ? void 0 : _job_lease.owner) !== owner) throw new LeaseLost();
561
+ if (extra) await extra(transaction, job);
562
+ const next = _extends({}, fields, {
563
+ lease: {
564
+ owner,
565
+ untilMs: ctx.now() + CONSENT_GROUP_CHANGE_LEASE_MS
566
+ },
567
+ failures: 0,
568
+ stalled: false,
569
+ lastError: null
570
+ });
571
+ transaction.update(jobRef, _extends({}, next, {
572
+ updatedAt: FieldValue.serverTimestamp()
573
+ }));
574
+ return _extends({}, job, next);
575
+ });
576
+ }
577
+ function addCounts(base, more) {
578
+ const out = _extends({}, base != null ? base : {});
579
+ for (const [key, value] of Object.entries(more)){
580
+ var _out_key;
581
+ if (typeof value === 'number' && Number.isFinite(value)) out[key] = ((_out_key = out[key]) != null ? _out_key : 0) + value;
582
+ }
583
+ return out;
584
+ }
585
+ async function runUnit(ctx, jobRef, job, owner, unit, deadlineMs) {
586
+ var _job_units_unit_key, _result_cursor, _result_counts;
587
+ const state = (_job_units_unit_key = job.units[unit.key]) != null ? _job_units_unit_key : {
588
+ cursor: null,
589
+ done: false
590
+ };
591
+ if (unit.kind === 'carry') {
592
+ var _job_counts_unit_store;
593
+ const carry = job.plan.carries[unit.carryIndex];
594
+ const page = await carryConsentGroupRefusals({
595
+ firestore: ctx.firestore,
596
+ store: unit.store,
597
+ carry,
598
+ changeId: job.changeId,
599
+ cursor: state.cursor,
600
+ sinceMs: unit.sinceMs
601
+ });
602
+ const counts = _extends({}, job.counts, {
603
+ [unit.store]: ((_job_counts_unit_store = job.counts[unit.store]) != null ? _job_counts_unit_store : 0) + page.written
604
+ });
605
+ const sitesReceiving = page.written > 0 && !job.sitesReceiving.includes(carry.toHostId) ? [
606
+ ...job.sitesReceiving,
607
+ carry.toHostId
608
+ ] : job.sitesReceiving;
609
+ return save(ctx, jobRef, owner, {
610
+ units: _extends({}, job.units, {
611
+ [unit.key]: {
612
+ cursor: page.cursor,
613
+ done: page.done
614
+ }
615
+ }),
616
+ counts,
617
+ sitesReceiving
618
+ });
619
+ }
620
+ const participant = participantFor(unit.pluginId);
621
+ const result = await participant.run({
622
+ orgId: job.orgId,
623
+ changeId: job.changeId,
624
+ plan: planOf(job),
625
+ phase: unit.phase,
626
+ cursor: state.cursor,
627
+ deadlineMs: deadlineMs - DEADLINE_SAFETY_MS,
628
+ dryRun: false
629
+ });
630
+ return save(ctx, jobRef, owner, {
631
+ units: _extends({}, job.units, {
632
+ [unit.key]: {
633
+ cursor: (_result_cursor = result.cursor) != null ? _result_cursor : null,
634
+ done: result.done === true
635
+ }
636
+ }),
637
+ plugins: _extends({}, job.plugins, {
638
+ [unit.pluginId]: addCounts(job.plugins[unit.pluginId], (_result_counts = result.counts) != null ? _result_counts : {})
639
+ })
640
+ });
641
+ }
642
+ function participantFor(pluginId) {
643
+ const entry = listPluginConsentGroupParticipants().find((candidate)=>candidate.pluginId === pluginId);
644
+ if (!entry) {
645
+ // The change started with this participant; running on without it would
646
+ // skip its share. Another process — one whose declarations registered it —
647
+ // picks the job up.
648
+ throw new Error(`The ${pluginId} plugin's part of this change is not available here`);
649
+ }
650
+ return entry.participant;
651
+ }
652
+ /** Moves the job to the step after its current one. */ async function nextStep(ctx, orgId, jobRef, job, owner) {
653
+ var _job_steps_;
654
+ const next = (_job_steps_ = job.steps[job.steps.indexOf(job.step) + 1]) != null ? _job_steps_ : 'finish';
655
+ const phase = consentGroupChangePhaseOf(next);
656
+ if (phase === job.phase) return save(ctx, jobRef, owner, {
657
+ step: next,
658
+ units: {}
659
+ });
660
+ // Entering the sweep: the marker says so, for the readers that hold off
661
+ // while holder records may still be moving.
662
+ const orgRef = orgDoc(ctx, orgId);
663
+ return save(ctx, jobRef, owner, {
664
+ step: next,
665
+ units: {},
666
+ phase
667
+ }, async (transaction)=>{
668
+ const marker = readConsentGroupsChange((await transaction.get(orgRef)).data());
669
+ if ((marker == null ? void 0 : marker.changeId) === job.changeId) {
670
+ transaction.update(orgRef, {
671
+ [`${CONSENT_GROUPS_CHANGE_FIELD}.phase`]: phase
672
+ });
673
+ }
674
+ });
675
+ }
676
+ /**
677
+ * THE FLIP — the one write of `consentGroups` anywhere in the platform.
678
+ *
679
+ * The marker must still name this change in its carry phase and the stored
680
+ * declaration must be the one the job started from; otherwise nothing is
681
+ * written and the unit fails. `update`, not a merge, so a removed group's id
682
+ * vanishes with it; an empty declaration deletes the field.
683
+ */ async function declare(ctx, jobRef, job, owner) {
684
+ const orgRef = orgDoc(ctx, job.orgId);
685
+ const nowMs = ctx.now();
686
+ const renameOnly = job.plan.renameOnly;
687
+ const declared = await save(ctx, jobRef, owner, renameOnly ? {
688
+ step: 'finish',
689
+ status: 'done',
690
+ phase: 'done',
691
+ declaredAtMs: nowMs,
692
+ finishedAtMs: nowMs,
693
+ units: {}
694
+ } : {
695
+ step: 'catch-up-post',
696
+ phase: 'rehome',
697
+ declaredAtMs: nowMs,
698
+ units: {}
699
+ }, async (transaction)=>{
700
+ const org = (await transaction.get(orgRef)).data();
701
+ const marker = readConsentGroupsChange(org);
702
+ if (!org || (marker == null ? void 0 : marker.changeId) !== job.changeId || marker.phase !== 'carry') {
703
+ throw new Error('The change no longer holds the organization, so the new groups were not saved');
704
+ }
705
+ if (consentGroupsFingerprint(org[CONSENT_GROUPS_FIELD]) !== job.beforeFingerprint) {
706
+ throw new Error('The consent groups changed outside this change, so the new groups were not saved');
707
+ }
708
+ transaction.update(orgRef, {
709
+ [CONSENT_GROUPS_FIELD]: Object.keys(job.after).length ? job.after : FieldValue.delete(),
710
+ [CONSENT_GROUPS_CHANGE_FIELD]: renameOnly ? FieldValue.delete() : _extends({}, marker, {
711
+ phase: 'rehome',
712
+ declaredAtMs: nowMs
713
+ }),
714
+ updatedAt: FieldValue.serverTimestamp()
715
+ });
716
+ });
717
+ const finished = renameOnly ? _extends({}, declared, {
718
+ lease: null
719
+ }) : declared;
720
+ if (renameOnly) await jobRef.update({
721
+ lease: null
722
+ });
723
+ for (const line of job.lines){
724
+ await ctx.log(job.orgId, job.actor, line, {
725
+ type: 'org',
726
+ id: job.orgId
727
+ });
728
+ }
729
+ if (renameOnly) {
730
+ await ctx.log(job.orgId, job.actor, 'Finished a consent group change', {
731
+ type: 'org',
732
+ id: job.orgId
733
+ });
734
+ }
735
+ return finished;
736
+ }
737
+ /** The "Finished" line: the carries' totals, then each participant's clause. */ function finishedLine(job) {
738
+ const copied = job.counts.siteSuppressions + job.counts.topicOptOuts + job.counts.paces;
739
+ const sites = job.sitesReceiving.length;
740
+ const clauses = [
741
+ `${copied} ${copied === 1 ? 'opt-out' : 'opt-outs'} copied to ${sites} ${sites === 1 ? 'site' : 'sites'}`
742
+ ];
743
+ for (const pluginId of job.participants){
744
+ var _job_plugins_pluginId;
745
+ var _entry_participant_summarize;
746
+ const entry = listPluginConsentGroupParticipants().find((candidate)=>candidate.pluginId === pluginId);
747
+ const clause = entry == null ? void 0 : (_entry_participant_summarize = entry.participant.summarize) == null ? void 0 : _entry_participant_summarize.call(entry.participant, (_job_plugins_pluginId = job.plugins[pluginId]) != null ? _job_plugins_pluginId : {});
748
+ if (clause) clauses.push(clause);
749
+ }
750
+ return `Finished a consent group change: ${clauses.join('; ')}`;
751
+ }
752
+ async function finish(ctx, jobRef, job, owner) {
753
+ const orgRef = orgDoc(ctx, job.orgId);
754
+ const nowMs = ctx.now();
755
+ const done = await save(ctx, jobRef, owner, {
756
+ status: 'done',
757
+ phase: 'done',
758
+ step: 'finish',
759
+ finishedAtMs: nowMs,
760
+ units: {}
761
+ }, async (transaction)=>{
762
+ const marker = readConsentGroupsChange((await transaction.get(orgRef)).data());
763
+ if ((marker == null ? void 0 : marker.changeId) === job.changeId) {
764
+ transaction.update(orgRef, {
765
+ [CONSENT_GROUPS_CHANGE_FIELD]: FieldValue.delete(),
766
+ updatedAt: FieldValue.serverTimestamp()
767
+ });
768
+ }
769
+ });
770
+ await jobRef.update({
771
+ lease: null
772
+ });
773
+ await ctx.log(job.orgId, job.actor, finishedLine(done), {
774
+ type: 'org',
775
+ id: job.orgId
776
+ });
777
+ return _extends({}, done, {
778
+ lease: null
779
+ });
780
+ }
781
+ /**
782
+ * One unit of work, or nothing when the job is waiting for its sweep.
783
+ */ async function advanceOnce(ctx, jobRef, job, owner, deadlineMs) {
784
+ var _ref, _ref1;
785
+ var _job_units_unit_key;
786
+ if (job.step === 'declare') return {
787
+ job: await declare(ctx, jobRef, job, owner),
788
+ idle: false
789
+ };
790
+ if (job.step === 'finish') return {
791
+ job: await finish(ctx, jobRef, job, owner),
792
+ idle: false
793
+ };
794
+ if (job.step === 'sweep-wait') {
795
+ var _job_declaredAtMs;
796
+ if (ctx.now() < ((_job_declaredAtMs = job.declaredAtMs) != null ? _job_declaredAtMs : 0) + CONSENT_GROUP_SWEEP_DELAY_MS) return {
797
+ job,
798
+ idle: true
799
+ };
800
+ return {
801
+ job: await nextStep(ctx, job.orgId, jobRef, job, owner),
802
+ idle: false
803
+ };
804
+ }
805
+ const unit = unitsOf(job).find((candidate)=>{
806
+ var _job_units_candidate_key;
807
+ return !((_job_units_candidate_key = job.units[candidate.key]) == null ? void 0 : _job_units_candidate_key.done);
808
+ });
809
+ if (!unit) return {
810
+ job: await nextStep(ctx, job.orgId, jobRef, job, owner),
811
+ idle: false
812
+ };
813
+ const before = (_ref = (_job_units_unit_key = job.units[unit.key]) == null ? void 0 : _job_units_unit_key.cursor) != null ? _ref : null;
814
+ const after = await runUnit(ctx, jobRef, job, owner, unit, deadlineMs);
815
+ const state = after.units[unit.key];
816
+ // A participant that stopped where it started — out of time before its
817
+ // first page — has nothing more to give this invocation.
818
+ return {
819
+ job: after,
820
+ idle: unit.kind === 'participant' && !(state == null ? void 0 : state.done) && ((_ref1 = state == null ? void 0 : state.cursor) != null ? _ref1 : null) === before
821
+ };
822
+ }
823
+ /** Claims the lease, or answers why not. `null` for a job that does not exist. */ async function claim(ctx, jobRef, owner) {
824
+ return ctx.firestore.runTransaction(async (transaction)=>{
825
+ const job = jobFrom(await transaction.get(jobRef));
826
+ if (!job) return null;
827
+ const nowMs = ctx.now();
828
+ if (job.status !== 'running') return {
829
+ job,
830
+ mine: false
831
+ };
832
+ if (job.lease && job.lease.untilMs > nowMs && job.lease.owner !== owner) return {
833
+ job,
834
+ mine: false
835
+ };
836
+ const lease = {
837
+ owner,
838
+ untilMs: nowMs + CONSENT_GROUP_CHANGE_LEASE_MS
839
+ };
840
+ transaction.update(jobRef, {
841
+ lease
842
+ });
843
+ return {
844
+ job: _extends({}, job, {
845
+ lease
846
+ }),
847
+ mine: true
848
+ };
849
+ });
850
+ }
851
+ async function release(ctx, jobRef, owner) {
852
+ return ctx.firestore.runTransaction(async (transaction)=>{
853
+ var _job_lease;
854
+ const job = jobFrom(await transaction.get(jobRef));
855
+ if (!job) return null;
856
+ if (((_job_lease = job.lease) == null ? void 0 : _job_lease.owner) !== owner) return job;
857
+ transaction.update(jobRef, {
858
+ lease: null
859
+ });
860
+ return _extends({}, job, {
861
+ lease: null
862
+ });
863
+ });
864
+ }
865
+ /** Counts a failed unit against the job, and says so once when it stalls. */ async function recordFailure(ctx, jobRef, owner, error) {
866
+ const lastError = sanitizeError(error);
867
+ const outcome = await ctx.firestore.runTransaction(async (transaction)=>{
868
+ var _job_failures;
869
+ var _job_lease;
870
+ const job = jobFrom(await transaction.get(jobRef));
871
+ if (!job) return null;
872
+ if (((_job_lease = job.lease) == null ? void 0 : _job_lease.owner) !== owner) return {
873
+ job,
874
+ newlyStalled: false
875
+ };
876
+ const failures = ((_job_failures = job.failures) != null ? _job_failures : 0) + 1;
877
+ const stalled = failures >= CONSENT_GROUP_CHANGE_STALL_AFTER;
878
+ const fields = {
879
+ failures,
880
+ stalled,
881
+ lastError,
882
+ lease: null
883
+ };
884
+ transaction.update(jobRef, _extends({}, fields, {
885
+ updatedAt: FieldValue.serverTimestamp()
886
+ }));
887
+ return {
888
+ job: _extends({}, job, fields),
889
+ newlyStalled: stalled && !job.stalled
890
+ };
891
+ });
892
+ if (!outcome) return null;
893
+ if (outcome.newlyStalled) {
894
+ await ctx.log(outcome.job.orgId, outcome.job.actor, 'A consent group change stopped with an error; it will try again', {
895
+ type: 'org',
896
+ id: outcome.job.orgId
897
+ });
898
+ }
899
+ console.error(`[consent-group-change] a unit of ${outcome.job.changeId} failed: ${lastError}`);
900
+ return outcome.job;
901
+ }
902
+ /**
903
+ * Advances a change until it finishes, waits for its sweep, or reaches
904
+ * `deadlineMs`. Answers the job's status either way — including when another
905
+ * caller holds it, which is not a failure.
906
+ */ export async function advanceConsentGroupChange(input) {
907
+ var _input_owner, _ref;
908
+ const ctx = contextOf(input);
909
+ const jobRef = jobDoc(ctx, input.orgId, input.changeId);
910
+ const owner = (_input_owner = input.owner) != null ? _input_owner : `advance:${createResourceUid()}`;
911
+ const claimed = await claim(ctx, jobRef, owner);
912
+ if (!claimed) return NOT_FOUND;
913
+ let job = claimed.job;
914
+ if (!claimed.mine) return {
915
+ ok: true,
916
+ status: consentGroupChangeStatus(job, ctx.now())
917
+ };
918
+ try {
919
+ while(job.status === 'running' && ctx.now() < input.deadlineMs - DEADLINE_SAFETY_MS){
920
+ const outcome = await advanceOnce(ctx, jobRef, job, owner, input.deadlineMs);
921
+ job = outcome.job;
922
+ if (outcome.idle) break;
923
+ }
924
+ } catch (error) {
925
+ var _jobFrom;
926
+ if (!(error instanceof LeaseLost)) {
927
+ var _ref1;
928
+ job = (_ref1 = await recordFailure(ctx, jobRef, owner, error)) != null ? _ref1 : job;
929
+ return {
930
+ ok: true,
931
+ status: consentGroupChangeStatus(job, ctx.now())
932
+ };
933
+ }
934
+ job = (_jobFrom = jobFrom(await jobRef.get())) != null ? _jobFrom : job;
935
+ return {
936
+ ok: true,
937
+ status: consentGroupChangeStatus(job, ctx.now())
938
+ };
939
+ }
940
+ if (job.status === 'running') job = (_ref = await release(ctx, jobRef, owner)) != null ? _ref : job;
941
+ return {
942
+ ok: true,
943
+ status: consentGroupChangeStatus(job, ctx.now())
944
+ };
945
+ }
946
+ /*==========================================
947
+ * CANCELING AND READING
948
+ *=========================================*/ /**
949
+ * Stops a change before it takes effect: clears the marker and leaves every
950
+ * refusal already carried, which only ever honors an opt-out on more sites.
951
+ * Refused once the declaration has flipped — there is nothing left to stop
952
+ * before, and the only way back is another change.
953
+ */ export async function cancelConsentGroupChange(input) {
954
+ var _input_actor_uid, _input_actor_email;
955
+ const ctx = contextOf(input);
956
+ const orgRef = orgDoc(ctx, input.orgId);
957
+ const jobRef = jobDoc(ctx, input.orgId, input.changeId);
958
+ const outcome = await ctx.firestore.runTransaction(async (transaction)=>{
959
+ const [orgSnapshot, jobSnapshot] = await Promise.all([
960
+ transaction.get(orgRef),
961
+ transaction.get(jobRef)
962
+ ]);
963
+ const job = jobFrom(jobSnapshot);
964
+ if (!job) return NOT_FOUND;
965
+ if (job.status !== 'running' || job.declaredAtMs) {
966
+ return {
967
+ ok: false,
968
+ status: 409,
969
+ body: {
970
+ error: job.status === 'running' || job.status === 'done' ? 'This change has already taken effect' : 'This change is no longer running',
971
+ changeId: job.changeId,
972
+ phase: job.phase
973
+ }
974
+ };
975
+ }
976
+ const marker = readConsentGroupsChange(orgSnapshot.data());
977
+ if ((marker == null ? void 0 : marker.changeId) === job.changeId) {
978
+ transaction.update(orgRef, {
979
+ [CONSENT_GROUPS_CHANGE_FIELD]: FieldValue.delete(),
980
+ updatedAt: FieldValue.serverTimestamp()
981
+ });
982
+ }
983
+ const fields = {
984
+ status: 'canceled',
985
+ phase: 'canceled',
986
+ canceledAtMs: ctx.now(),
987
+ lease: null
988
+ };
989
+ transaction.update(jobRef, _extends({}, fields, {
990
+ updatedAt: FieldValue.serverTimestamp()
991
+ }));
992
+ return {
993
+ ok: true,
994
+ job: _extends({}, job, fields)
995
+ };
996
+ });
997
+ if (isRefusal(outcome)) return outcome;
998
+ const { job } = outcome;
999
+ await ctx.log(input.orgId, {
1000
+ uid: (_input_actor_uid = input.actor.uid) != null ? _input_actor_uid : null,
1001
+ email: (_input_actor_email = input.actor.email) != null ? _input_actor_email : null
1002
+ }, 'Canceled a consent group change before it took effect', {
1003
+ type: 'org',
1004
+ id: input.orgId
1005
+ });
1006
+ return {
1007
+ ok: true,
1008
+ status: consentGroupChangeStatus(job, ctx.now())
1009
+ };
1010
+ }
1011
+ /** A job's status, or `null` for a change the org never had. */ export async function readConsentGroupChangeStatus(input) {
1012
+ const ctx = contextOf(input);
1013
+ const job = jobFrom(await jobDoc(ctx, input.orgId, input.changeId).get());
1014
+ return job ? consentGroupChangeStatus(job, ctx.now()) : null;
1015
+ }
1016
+ /**
1017
+ * Advances every change in flight, oldest org id first, until `deadlineMs`.
1018
+ *
1019
+ * A change nobody is watching still finishes: the progress panel is a
1020
+ * convenience, and this is the guarantee. A lockdown on the org — or on the
1021
+ * member who started the change — pauses it, like every other write the
1022
+ * workspace makes. A marker whose job document is gone is a change nothing
1023
+ * can finish, and is cleared rather than blocking every change after it.
1024
+ */ export async function advanceDueConsentGroupChanges(input) {
1025
+ var _input_limit;
1026
+ const ctx = contextOf(input);
1027
+ const snapshot = await ctx.firestore.collection('orgs').where(`${CONSENT_GROUPS_CHANGE_FIELD}.phase`, 'in', [
1028
+ ...RUNNING_PHASES
1029
+ ]).limit((_input_limit = input.limit) != null ? _input_limit : 50).get();
1030
+ const reports = [];
1031
+ for (const org of snapshot.docs){
1032
+ const data = org.data();
1033
+ const marker = readConsentGroupsChange(data);
1034
+ if (!marker) continue;
1035
+ const base = {
1036
+ orgId: org.id,
1037
+ changeId: marker.changeId,
1038
+ phase: marker.phase
1039
+ };
1040
+ if (input.dryRun) {
1041
+ reports.push(_extends({}, base, {
1042
+ outcome: 'listed'
1043
+ }));
1044
+ continue;
1045
+ }
1046
+ if (ctx.now() >= input.deadlineMs - DEADLINE_SAFETY_MS) {
1047
+ reports.push(_extends({}, base, {
1048
+ outcome: 'skipped'
1049
+ }));
1050
+ continue;
1051
+ }
1052
+ try {
1053
+ var _ref;
1054
+ var _job_actor;
1055
+ const job = jobFrom(await jobDoc(ctx, org.id, marker.changeId).get());
1056
+ if (!job) {
1057
+ await orgDoc(ctx, org.id).update({
1058
+ [CONSENT_GROUPS_CHANGE_FIELD]: FieldValue.delete()
1059
+ });
1060
+ console.error(`[consent-group-change] cleared a marker with no job in org ${org.id}`);
1061
+ reports.push(_extends({}, base, {
1062
+ outcome: 'orphaned'
1063
+ }));
1064
+ continue;
1065
+ }
1066
+ if (await ctx.lockedOut(data, (_ref = (_job_actor = job.actor) == null ? void 0 : _job_actor.uid) != null ? _ref : null)) {
1067
+ reports.push(_extends({}, base, {
1068
+ outcome: 'locked'
1069
+ }));
1070
+ continue;
1071
+ }
1072
+ const advanced = await advanceConsentGroupChange({
1073
+ orgId: org.id,
1074
+ changeId: marker.changeId,
1075
+ deadlineMs: input.deadlineMs,
1076
+ owner: `cron:${createResourceUid()}`,
1077
+ firestore: ctx.firestore,
1078
+ log: ctx.log,
1079
+ now: ctx.now
1080
+ });
1081
+ reports.push(_extends({}, base, {
1082
+ outcome: 'advanced'
1083
+ }, advanced.ok ? {
1084
+ status: advanced.status
1085
+ } : {}));
1086
+ } catch (error) {
1087
+ reports.push(_extends({}, base, {
1088
+ outcome: 'advanced',
1089
+ error: sanitizeError(error)
1090
+ }));
1091
+ }
1092
+ }
1093
+ return reports;
1094
+ }
1095
+
1096
+ //# sourceMappingURL=consent-group-change.js.map