@aglyn/tenant-data-admin 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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-change.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * RUNNING A CONSENT GROUP CHANGE (AGL-3320) — the executor.\n *\n * `consent-group-change.ts` in `app-utils` decides WHAT a change requires;\n * this carries it out, across as many invocations as it takes, without ever\n * leaving a refusal unhonored in between.\n *\n * ## The order is the safety argument\n *\n * 1. START — one transaction on the org document: the stored declaration is\n * the one the editor rendered, and no other change is in flight. It sets\n * the marker `consentGroupsChange` and creates the job document.\n * 2. CARRY — every carry of the plan through the three platform stores\n * (`consent-group-carry.ts`), then every participant's carry phase.\n * 3. CATCH UP — everything filed since the carry began, again.\n * 4. DECLARE — one transaction: the marker still names this change in its\n * carry phase and the stored declaration is still the one the job\n * started from. Only then is the new declaration written.\n * 5. CATCH UP — once more, for whatever raced the flip.\n * 6. RE-HOME — every participant's holder records.\n * 7. SWEEP — once the sweep delay has passed, the whole carry and every\n * participant's sweep, idempotently.\n * 8. DONE — the marker is cleared.\n *\n * The flip cannot happen before every carry and both catch-up passes are\n * done, because the steps run strictly in order and `declare` is only ever\n * reached from a finished `catch-up-pre`. Until the flip the declaration in\n * force is the old one, so a change that stalls or is canceled before it\n * has changed nothing anybody can observe — except refusals honored on more\n * sites, which is the safe direction. A rename moves nothing, and runs the\n * declare and finish alone.\n *\n * ## One invocation at a time, and any invocation may finish it\n *\n * The console's progress panel calls `continue` while it is open, and the\n * cron backstop calls every fifteen minutes. Each claims a LEASE on the job\n * by transaction and renews it on every unit it saves; a lease that lapses\n * (a process that died) is simply claimed by the next caller. A unit is one\n * page of one store, or one participant call, and every unit is idempotent,\n * so a unit re-run after a crash writes nothing it already wrote.\n *\n * ## A failure is retried, and five in a row are shown\n *\n * A unit that throws releases the lease and counts a failure against the job;\n * the next invocation retries it from its cursor. Five consecutive failures\n * mark the job STALLED — the activity log says so once, and the panel shows\n * the error with a retry — and the cron keeps trying. A participant's throw\n * is one of these: the change never flips past a participant that could not\n * carry its refusals (see `plugin-consent-group-change.ts`).\n */\n\nimport {\n CONSENT_GROUP_CHANGE_STEPS,\n CONSENT_GROUP_CHANGES_COLLECTION,\n CONSENT_GROUP_RENAME_STEPS,\n CONSENT_GROUP_SWEEP_DELAY_MS,\n CONSENT_GROUPS_CHANGE_FIELD,\n type ConsentGroupChangeCounts,\n type ConsentGroupChangeLine,\n type ConsentGroupChangePlan,\n type ConsentGroupChangePreview,\n type ConsentGroupChangeStatus,\n type ConsentGroupChangeStep,\n type ConsentGroupChangeProgress,\n type ConsentGroupDeclaration,\n type ConsentGroupsApiInFlight,\n type ConsentGroupsApiRefusal,\n type ConsentGroupsApiStale,\n type ConsentGroupsChangeMarker,\n type ConsentGroupsChangePhase,\n consentGroupChangePhaseOf,\n consentGroupDisclosureChanges,\n consentGroupsFingerprint,\n describeConsentGroupChangeLine,\n discardedConsentGroupIds,\n estimateConsentGroupChange,\n orgSiteIds,\n planConsentGroupChange,\n readConsentGroupsChange,\n validateConsentGroupDeclaration,\n} from '@aglyn/aglyn/app-utils/consent-group-change'\nimport {\n CONSENT_GROUPS_FIELD,\n consentGroupsAwaitConfirmation,\n readConsentGroups,\n} from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n DEFAULT_EMAIL_TOPICS,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport { resolveMarketingConsentPolicy } from '@aglyn/aglyn/app-utils/marketing-consent'\nimport { isOrgWideMember } from '@aglyn/aglyn/app-utils/organizations'\nimport {\n type ConsentGroupChangeParticipant,\n type ConsentGroupChangeParticipantPhase,\n listPluginConsentGroupParticipants,\n previewPluginConsentGroupChange,\n} from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONSENT_GROUP_CARRY_STORES,\n CONSENT_GROUP_CARRY_SUBCOLLECTIONS,\n type ConsentGroupCarryStore,\n carryConsentGroupRefusals,\n} from './consent-group-carry'\nimport firebaseAdmin from './firebase-admin'\nimport { getLockdownVerdict } from './lockdown'\nimport { logOrgActivity } from './organizations'\n\n/** How long a claimed job stays claimed without a renewal. */\nexport const CONSENT_GROUP_CHANGE_LEASE_MS = 90_000\n\n/** Consecutive failed units before a job is shown as stalled. */\nexport const CONSENT_GROUP_CHANGE_STALL_AFTER = 5\n\n/**\n * How far before the carry began a catch-up reads. A refusal filed in the\n * seconds the carry's first page was being read, with a clock a little\n * behind, is inside it.\n */\nconst CATCH_UP_OVERLAP_MS = 5 * 60_000\n\n/** Left before a deadline for the last save. */\nconst DEADLINE_SAFETY_MS = 5_000\n\n/** The phases the cron looks for a change in. */\nconst RUNNING_PHASES: readonly ConsentGroupsChangePhase[] = ['carry', 'rehome', 'sweep']\n\n/** Who asked, as the activity log records it. */\nexport interface ConsentGroupChangeActor {\n uid: string | null\n email?: string | null\n}\n\n/** Everything the executor reaches outside itself, injectable for the specs. */\nexport interface ConsentGroupChangeContext {\n firestore?: FirebaseFirestore.Firestore\n log?: typeof logOrgActivity\n now?: () => number\n /** Whether a lockdown refuses the cron acting for this org and actor. */\n lockedOut?: (org: Record<string, unknown>, uid: string | null) => Promise<boolean>\n}\n\ninterface Context {\n firestore: FirebaseFirestore.Firestore\n log: typeof logOrgActivity\n now: () => number\n lockedOut: (org: Record<string, unknown>, uid: string | null) => Promise<boolean>\n}\n\nfunction contextOf(input: ConsentGroupChangeContext): Context {\n return {\n firestore: input.firestore ?? firebaseAdmin.app().firestore(),\n log: input.log ?? logOrgActivity,\n now: input.now ?? Date.now,\n lockedOut:\n input.lockedOut ??\n (async (org, uid) =>\n (await getLockdownVerdict({ org: org as never, uid, intent: 'write' })) !== null),\n }\n}\n\n/** Where a unit of the current step stands. */\ninterface UnitState {\n cursor: string | null\n done: boolean\n}\n\n/** The job document, `orgs/{orgId}/consentGroupChanges/{changeId}`. */\nexport interface ConsentGroupChangeJob {\n changeId: string\n orgId: string\n status: ConsentGroupChangeStatus\n step: ConsentGroupChangeStep\n /** The marker's phase while running; `done` or `canceled` after. */\n phase: ConsentGroupsChangePhase | 'done' | 'canceled'\n /** The steps this change runs, in order. */\n steps: ConsentGroupChangeStep[]\n /** The stored declaration it started from: what the flip re-checks. */\n beforeFingerprint: string\n before: ConsentGroupDeclaration\n after: ConsentGroupDeclaration\n plan: Omit<ConsentGroupChangePlan, 'before' | 'after'>\n /** The plan's lines as the activity log writes them. */\n lines: string[]\n siteNames: Record<string, string>\n /** The participants registered when it started — every one of them runs. */\n participants: string[]\n /** The current step's units, by key. */\n units: Record<string, UnitState>\n counts: ConsentGroupChangeCounts\n sitesReceiving: string[]\n plugins: Record<string, Record<string, number>>\n lease: { owner: string; untilMs: number } | null\n failures: number\n stalled: boolean\n lastError: string | null\n actor: { uid: string | null; email: string | null }\n startedAtMs: number\n carryStartedAtMs: number\n declaredAtMs: number | null\n finishedAtMs: number | null\n canceledAtMs: number | null\n}\n\n/** What the route answers for a job. */\nexport interface ConsentGroupChangeStatusBody {\n changeId: string\n phase: ConsentGroupsChangePhase | 'done' | 'canceled'\n done: boolean\n progress: ConsentGroupChangeProgress\n}\n\n/** A refusal the route turns into its status code and body. */\nexport type ConsentGroupChangeRefusal =\n | { ok: false; status: 400; body: ConsentGroupsApiRefusal }\n | { ok: false; status: 404; body: { error: string } }\n | { ok: false; status: 409; body: ConsentGroupsApiStale | ConsentGroupsApiInFlight }\n\nconst NOT_FOUND = { ok: false as const, status: 404 as const, body: { error: 'No such consent group change' } }\n\n/** A caller whose lease was taken, or whose job was canceled, under it. */\nclass LeaseLost extends Error {}\n\nconst orgDoc = (ctx: Context, orgId: string) => ctx.firestore.collection('orgs').doc(orgId)\nconst jobDoc = (ctx: Context, orgId: string, changeId: string) =>\n orgDoc(ctx, orgId).collection(CONSENT_GROUP_CHANGES_COLLECTION).doc(changeId)\n\n/** The job as the plan the participants and the carries read. */\nfunction planOf(job: ConsentGroupChangeJob): ConsentGroupChangePlan {\n return { ...job.plan, before: job.before, after: job.after }\n}\n\n/** An error as the job records it: bounded, and with no address in it. */\nfunction sanitizeError(error: unknown): string {\n const message = error instanceof Error ? error.message : String(error ?? 'Unknown error')\n return message.replace(/[^\\s@]+@[^\\s@]+/g, '[address]').slice(0, 300) || 'Unknown error'\n}\n\nexport function consentGroupChangeStatus(\n job: ConsentGroupChangeJob,\n nowMs: number = Date.now(),\n): ConsentGroupChangeStatusBody {\n const declaredAtMs = job.declaredAtMs ?? null\n const index = job.steps.indexOf(job.step)\n return {\n changeId: job.changeId,\n phase: job.phase,\n done: job.status !== 'running',\n progress: {\n status: job.status,\n step: job.step,\n stepIndex: index >= 0 ? index + 1 : job.steps.length,\n stepCount: job.steps.length,\n declaredAtMs,\n sweepAtMs:\n declaredAtMs !== null && !job.plan.renameOnly\n ? declaredAtMs + CONSENT_GROUP_SWEEP_DELAY_MS\n : null,\n stalled: job.stalled === true,\n failures: job.failures ?? 0,\n lastError: job.lastError ?? null,\n leaseUntilMs: job.lease && job.lease.untilMs > nowMs ? job.lease.untilMs : null,\n counts: { ...job.counts },\n sitesReceiving: job.sitesReceiving?.length ?? 0,\n plugins: job.plugins ?? {},\n },\n }\n}\n\n/*==========================================\n * VALIDATING AND PLANNING — shared by the preview and the start\n *=========================================*/\n\ninterface Prepared {\n org: Record<string, unknown>\n before: ConsentGroupDeclaration\n after: ConsentGroupDeclaration\n plan: ConsentGroupChangePlan\n siteIds: string[]\n}\n\nfunction prepare(\n org: Record<string, unknown>,\n expected: unknown,\n groups: unknown,\n): Prepared | ConsentGroupChangeRefusal {\n const marker = readConsentGroupsChange(org)\n if (marker) {\n return {\n ok: false,\n status: 409,\n body: {\n error: 'A consent group change is still finishing',\n changeId: marker.changeId,\n phase: marker.phase,\n },\n }\n }\n const raw = org[CONSENT_GROUPS_FIELD]\n if (consentGroupsFingerprint(raw) !== consentGroupsFingerprint(expected)) {\n return {\n ok: false,\n status: 409,\n body: {\n error: 'Someone else changed consent groups while you were editing',\n current:\n raw && typeof raw === 'object' && !Array.isArray(raw)\n ? (raw as Record<string, unknown>)\n : null,\n },\n }\n }\n const before = readConsentGroups(org)\n const siteIds = orgSiteIds(org)\n const validation = validateConsentGroupDeclaration({\n groups,\n before,\n siteIds,\n reserved:\n raw && typeof raw === 'object' && !Array.isArray(raw)\n ? Object.keys(raw as Record<string, unknown>)\n : [],\n })\n if (!validation.ok) {\n return {\n ok: false,\n status: 400,\n body: {\n error: 'Those consent groups cannot be saved',\n errors: (validation as { errors: ConsentGroupsApiRefusal['errors'] }).errors,\n },\n }\n }\n const after = (validation as { after: ConsentGroupDeclaration }).after\n return {\n org,\n before,\n after,\n plan: planConsentGroupChange(before, after, { siteIds }),\n siteIds,\n }\n}\n\nconst isRefusal = (value: unknown): value is ConsentGroupChangeRefusal =>\n Boolean(value && typeof value === 'object' && (value as { ok?: unknown }).ok === false)\n\n/** Every site a plan names, for the names its lines are written with. */\nfunction plannedSites(plan: ConsentGroupChangePlan): string[] {\n return [\n ...new Set([\n ...plan.hostIds,\n ...Object.values(plan.before).flatMap((group) => group.hostIds),\n ...Object.values(plan.after).flatMap((group) => group.hostIds),\n ]),\n ]\n}\n\n/** What the console calls each site: its name, its subdomain, or its id. */\nasync function siteNamesFor(ctx: Context, hostIds: readonly string[]): Promise<Record<string, string>> {\n if (!hostIds.length) return {}\n const hosts = ctx.firestore.collection('hosts')\n const snapshots = await ctx.firestore.getAll(...hostIds.map((id) => hosts.doc(id)))\n const names: Record<string, string> = {}\n snapshots.forEach((snapshot, index) => {\n const id = hostIds[index]\n names[id] = String(snapshot.get('name') || snapshot.get('subdomain') || id)\n })\n return names\n}\n\nconst describeLines = (lines: readonly ConsentGroupChangeLine[], names: Record<string, string>) =>\n lines.map((line) => describeConsentGroupChangeLine(line, (hostId) => names[hostId] ?? hostId))\n\n/*==========================================\n * THE PREVIEW\n *=========================================*/\n\nasync function countOf(query: FirebaseFirestore.Query): Promise<number> {\n const snapshot = await query.count().get()\n return Number(snapshot.data().count) || 0\n}\n\n/**\n * What a change would do, counted and worded, writing nothing.\n *\n * Refused exactly as `apply` would be — a change in flight, a stale\n * `expected`, an invalid declaration — so the review step never shows a\n * change the next click cannot make.\n */\nexport async function previewConsentGroupChange(\n input: {\n orgId: string\n expected: unknown\n groups: unknown\n /** The org document, when the caller already read it. */\n org?: Record<string, unknown> | null\n } & ConsentGroupChangeContext,\n): Promise<{ ok: true; preview: ConsentGroupChangePreview } | ConsentGroupChangeRefusal> {\n const ctx = contextOf(input)\n const org =\n input.org ?? ((await orgDoc(ctx, input.orgId).get()).data() as Record<string, unknown> | undefined)\n if (!org) return { ok: false, status: 404, body: { error: 'No such workspace' } }\n const prepared = prepare(org, input.expected, input.groups)\n if (isRefusal(prepared)) return prepared\n const { plan, before, after } = prepared as Prepared\n\n const db = ctx.firestore\n const cache = new Map<string, Promise<number>>()\n const cached = (key: string, query: () => FirebaseFirestore.Query) => {\n let pending = cache.get(key)\n if (!pending) {\n pending = countOf(query())\n cache.set(key, pending)\n }\n return pending\n }\n const store = (hostId: string, name: ConsentGroupCarryStore) =>\n db.collection('hosts').doc(hostId).collection(CONSENT_GROUP_CARRY_SUBCOLLECTIONS[name])\n const suppressions = (hostId: string) => cached(`s:${hostId}`, () => store(hostId, 'siteSuppressions'))\n const optOuts = (hostId: string) => cached(`t:${hostId}`, () => store(hostId, 'topicOptOuts'))\n const paces = (hostId: string) =>\n cached(`p:${hostId}`, () => store(hostId, 'paces').where('cadence', '!=', null))\n\n const carries = await Promise.all(\n plan.carries.map(async (carry) => ({\n toHostId: carry.toHostId,\n fromHostId: carry.fromHostId,\n siteSuppressions: await suppressions(carry.fromHostId),\n topicOptOuts: await optOuts(carry.fromHostId),\n paces: await paces(carry.fromHostId),\n })),\n )\n\n const membersIn = (declaration: ConsentGroupDeclaration, hostId: string) =>\n Object.values(declaration).find((group) => group.hostIds.includes(hostId))?.hostIds ?? [hostId]\n const joining: Array<{ hostId: string; siblings: string[] }> = []\n for (const hostId of [...new Set(Object.values(after).flatMap((group) => group.hostIds))].sort()) {\n const had = new Set(membersIn(before, hostId))\n const siblings = membersIn(after, hostId).filter((id) => id !== hostId && !had.has(id))\n if (siblings.length) joining.push({ hostId, siblings })\n }\n const inherited = await Promise.all(\n joining.map(async ({ hostId, siblings }) => {\n let refusals = 0\n for (const sibling of siblings) {\n refusals += (await suppressions(sibling)) + (await optOuts(sibling))\n }\n return { hostId, refusals }\n }),\n )\n\n /*\n * A pending confirmation holds a sibling's mail only with the switch on,\n * so only then does a separation release anything. Counted over the\n * built-in streams: the catalog of an organization's own streams belongs to\n * the Email plugin, and a stream it added is not counted here.\n */\n const pendingHolds: ConsentGroupChangePreview['pendingHolds'] = []\n if (consentGroupsAwaitConfirmation(org)) {\n const released = new Map<string, string[]>()\n for (const carry of plan.carries) {\n released.set(carry.toHostId, [...(released.get(carry.toHostId) ?? []), carry.fromHostId])\n }\n for (const [hostId, releasedHostIds] of [...released.entries()].sort()) {\n for (const topic of DEFAULT_EMAIL_TOPICS) {\n const count = await countOf(\n db\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .where(`topics.${topic.id}.confirmedAt`, '==', null),\n )\n if (count > 0) pendingHolds.push({ hostId, topicId: topic.id, count, releasedHostIds })\n }\n }\n }\n\n const changedGroups = Object.entries(after)\n .filter(([id, group]) => {\n const was = before[id]\n return !was || was.hostIds.join('\\n') !== group.hostIds.join('\\n')\n })\n .map(([, group]) => group.hostIds)\n let partialAccessMembers = 0\n if (changedGroups.length) {\n const members = await orgDoc(ctx, input.orgId).collection('members').get()\n for (const member of members.docs) {\n const data = member.data() as Record<string, unknown>\n if (isOrgWideMember(data)) continue\n const reach = new Set(Object.keys((data['hostAccess'] ?? {}) as Record<string, unknown>))\n if (\n changedGroups.some(\n (hostIds) =>\n hostIds.some((id) => reach.has(id)) && !hostIds.every((id) => reach.has(id)),\n )\n ) {\n partialAccessMembers += 1\n }\n }\n }\n\n const policy = resolveMarketingConsentPolicy(org['marketingConsentPolicy'])\n const forwardPolicyWarning =\n policy.mode === 'forward' && joining.length\n ? { hostIds: joining.map((entry) => entry.hostId), enforceFromMs: policy.enforceFromMs }\n : null\n\n const participants = await previewPluginConsentGroupChange({ orgId: input.orgId, plan })\n const names = await siteNamesFor(ctx, plannedSites(plan))\n const texts = describeLines(plan.lines, names)\n const documents =\n carries.reduce((total, carry) => total + carry.siteSuppressions + carry.topicOptOuts + carry.paces, 0) +\n participants.reduce(\n (total, entry) =>\n total + (entry.lines ?? []).reduce((sum, line) => sum + (line.count ?? 0), 0),\n 0,\n )\n\n return {\n ok: true,\n preview: {\n before,\n after,\n discarded: discardedConsentGroupIds(org),\n lines: plan.lines.map((line, index) => ({ ...line, text: texts[index] })),\n disclosures: consentGroupDisclosureChanges(before, after),\n carries,\n inherited,\n pendingHolds,\n partialAccessMembers,\n forwardPolicyWarning,\n participants,\n capturesDisclosing: ['form'],\n estimate: estimateConsentGroupChange(plan, documents),\n },\n }\n}\n\n/*==========================================\n * STARTING\n *=========================================*/\n\n/**\n * Starts a change: the org marker and the job, in one transaction that\n * refuses a change in flight, a stale `expected` and an invalid declaration.\n * Writes nothing else — the caller advances it.\n */\nexport async function startConsentGroupChange(\n input: {\n orgId: string\n actor: ConsentGroupChangeActor\n expected: unknown\n groups: unknown\n } & ConsentGroupChangeContext,\n): Promise<{ ok: true; changeId: string; job: ConsentGroupChangeJob } | ConsentGroupChangeRefusal> {\n const ctx = contextOf(input)\n const orgRef = orgDoc(ctx, input.orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return { ok: false, status: 404, body: { error: 'No such workspace' } }\n const outside = prepare(snapshot.data() as Record<string, unknown>, input.expected, input.groups)\n if (isRefusal(outside)) return outside\n // Names are words for the log, read once and outside the transaction.\n const names = await siteNamesFor(ctx, plannedSites((outside as Prepared).plan))\n const participants = listPluginConsentGroupParticipants().map((entry) => entry.pluginId)\n const changeId = createResourceUid()\n const jobRef = jobDoc(ctx, input.orgId, changeId)\n const nowMs = ctx.now()\n\n const started = await ctx.firestore.runTransaction(async (transaction) => {\n const current = await transaction.get(orgRef)\n if (!current.exists) {\n return { ok: false as const, status: 404 as const, body: { error: 'No such workspace' } }\n }\n const prepared = prepare(current.data() as Record<string, unknown>, input.expected, input.groups)\n if (isRefusal(prepared)) return prepared\n const { plan, before, after, org } = prepared as Prepared\n const steps = plan.renameOnly ? [...CONSENT_GROUP_RENAME_STEPS] : [...CONSENT_GROUP_CHANGE_STEPS]\n const job: ConsentGroupChangeJob = {\n changeId,\n orgId: input.orgId,\n status: 'running',\n step: steps[0],\n phase: 'carry',\n steps,\n beforeFingerprint: consentGroupsFingerprint(org[CONSENT_GROUPS_FIELD]),\n before,\n after,\n plan: {\n carries: plan.carries,\n flows: plan.flows,\n lines: plan.lines,\n hostIds: plan.hostIds,\n renameOnly: plan.renameOnly,\n },\n lines: describeLines(plan.lines, names),\n siteNames: names,\n participants: plan.renameOnly ? [] : participants,\n units: {},\n counts: { siteSuppressions: 0, topicOptOuts: 0, paces: 0 },\n sitesReceiving: [],\n plugins: {},\n lease: null,\n failures: 0,\n stalled: false,\n lastError: null,\n actor: { uid: input.actor.uid ?? null, email: input.actor.email ?? null },\n startedAtMs: nowMs,\n carryStartedAtMs: nowMs,\n declaredAtMs: null,\n finishedAtMs: null,\n canceledAtMs: null,\n }\n const marker: ConsentGroupsChangeMarker = {\n changeId,\n phase: 'carry',\n hostIds: plan.hostIds,\n startedAtMs: nowMs,\n }\n transaction.update(orgRef, {\n [CONSENT_GROUPS_CHANGE_FIELD]: marker,\n updatedAt: FieldValue.serverTimestamp(),\n })\n transaction.create(jobRef, {\n ...job,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return { ok: true as const, job }\n })\n if (isRefusal(started)) return started\n const { job } = started as { job: ConsentGroupChangeJob }\n await ctx.log(\n input.orgId,\n { uid: job.actor.uid, email: job.actor.email },\n `Started a consent group change: ${job.lines.join('; ')}`,\n { type: 'org', id: input.orgId },\n )\n return { ok: true, changeId, job }\n}\n\n/*==========================================\n * ADVANCING\n *=========================================*/\n\ntype Unit =\n | { kind: 'carry'; key: string; carryIndex: number; store: ConsentGroupCarryStore; sinceMs: number | null }\n | { kind: 'participant'; key: string; pluginId: string; phase: ConsentGroupChangeParticipantPhase }\n\n/** The current step's units, in the order they run. */\nfunction unitsOf(job: ConsentGroupChangeJob): Unit[] {\n const carries = (sinceMs: number | null): Unit[] =>\n job.plan.carries.flatMap((_carry, carryIndex) =>\n CONSENT_GROUP_CARRY_STORES.map((store) => ({\n kind: 'carry' as const,\n key: `c${carryIndex}:${store}`,\n carryIndex,\n store,\n sinceMs,\n })),\n )\n const participants = (phase: ConsentGroupChangeParticipantPhase): Unit[] =>\n job.participants.map((pluginId) => ({\n kind: 'participant' as const,\n key: `p:${pluginId}`,\n pluginId,\n phase,\n }))\n const since = job.carryStartedAtMs - CATCH_UP_OVERLAP_MS\n switch (job.step) {\n case 'carry':\n return [...carries(null), ...participants('carry')]\n case 'catch-up-pre':\n case 'catch-up-post':\n return [...carries(since), ...participants('carry')]\n case 'rehome':\n return participants('rehome')\n case 'sweep':\n return [...carries(null), ...participants('sweep')]\n default:\n return []\n }\n}\n\nfunction jobFrom(snapshot: FirebaseFirestore.DocumentSnapshot): ConsentGroupChangeJob | null {\n return snapshot.exists ? (snapshot.data() as ConsentGroupChangeJob) : null\n}\n\n/**\n * Saves a unit's result, renewing the lease — only while the job is still\n * running and still this caller's.\n */\nasync function save(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n owner: string,\n fields: Partial<ConsentGroupChangeJob>,\n extra?: (transaction: FirebaseFirestore.Transaction, job: ConsentGroupChangeJob) => Promise<void>,\n): Promise<ConsentGroupChangeJob> {\n return ctx.firestore.runTransaction(async (transaction) => {\n const job = jobFrom(await transaction.get(jobRef))\n if (!job || job.status !== 'running' || job.lease?.owner !== owner) throw new LeaseLost()\n if (extra) await extra(transaction, job)\n const next: Partial<ConsentGroupChangeJob> = {\n ...fields,\n lease: { owner, untilMs: ctx.now() + CONSENT_GROUP_CHANGE_LEASE_MS },\n failures: 0,\n stalled: false,\n lastError: null,\n }\n transaction.update(jobRef, { ...next, updatedAt: FieldValue.serverTimestamp() })\n return { ...job, ...next }\n })\n}\n\nfunction addCounts(\n base: Record<string, number> | undefined,\n more: Record<string, number>,\n): Record<string, number> {\n const out: Record<string, number> = { ...(base ?? {}) }\n for (const [key, value] of Object.entries(more)) {\n if (typeof value === 'number' && Number.isFinite(value)) out[key] = (out[key] ?? 0) + value\n }\n return out\n}\n\nasync function runUnit(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n job: ConsentGroupChangeJob,\n owner: string,\n unit: Unit,\n deadlineMs: number,\n): Promise<ConsentGroupChangeJob> {\n const state = job.units[unit.key] ?? { cursor: null, done: false }\n if (unit.kind === 'carry') {\n const carry = job.plan.carries[unit.carryIndex]\n const page = await carryConsentGroupRefusals({\n firestore: ctx.firestore,\n store: unit.store,\n carry,\n changeId: job.changeId,\n cursor: state.cursor,\n sinceMs: unit.sinceMs,\n })\n const counts = { ...job.counts, [unit.store]: (job.counts[unit.store] ?? 0) + page.written }\n const sitesReceiving =\n page.written > 0 && !job.sitesReceiving.includes(carry.toHostId)\n ? [...job.sitesReceiving, carry.toHostId]\n : job.sitesReceiving\n return save(ctx, jobRef, owner, {\n units: { ...job.units, [unit.key]: { cursor: page.cursor, done: page.done } },\n counts,\n sitesReceiving,\n })\n }\n const participant = participantFor(unit.pluginId)\n const result = await participant.run({\n orgId: job.orgId,\n changeId: job.changeId,\n plan: planOf(job),\n phase: unit.phase,\n cursor: state.cursor,\n deadlineMs: deadlineMs - DEADLINE_SAFETY_MS,\n dryRun: false,\n })\n return save(ctx, jobRef, owner, {\n units: { ...job.units, [unit.key]: { cursor: result.cursor ?? null, done: result.done === true } },\n plugins: { ...job.plugins, [unit.pluginId]: addCounts(job.plugins[unit.pluginId], result.counts ?? {}) },\n })\n}\n\nfunction participantFor(pluginId: string): ConsentGroupChangeParticipant {\n const entry = listPluginConsentGroupParticipants().find((candidate) => candidate.pluginId === pluginId)\n if (!entry) {\n // The change started with this participant; running on without it would\n // skip its share. Another process — one whose declarations registered it —\n // picks the job up.\n throw new Error(`The ${pluginId} plugin's part of this change is not available here`)\n }\n return entry.participant\n}\n\n/** Moves the job to the step after its current one. */\nasync function nextStep(\n ctx: Context,\n orgId: string,\n jobRef: FirebaseFirestore.DocumentReference,\n job: ConsentGroupChangeJob,\n owner: string,\n): Promise<ConsentGroupChangeJob> {\n const next = job.steps[job.steps.indexOf(job.step) + 1] ?? 'finish'\n const phase = consentGroupChangePhaseOf(next)\n if (phase === job.phase) return save(ctx, jobRef, owner, { step: next, units: {} })\n // Entering the sweep: the marker says so, for the readers that hold off\n // while holder records may still be moving.\n const orgRef = orgDoc(ctx, orgId)\n return save(ctx, jobRef, owner, { step: next, units: {}, phase }, async (transaction) => {\n const marker = readConsentGroupsChange((await transaction.get(orgRef)).data())\n if (marker?.changeId === job.changeId) {\n transaction.update(orgRef, { [`${CONSENT_GROUPS_CHANGE_FIELD}.phase`]: phase })\n }\n })\n}\n\n/**\n * THE FLIP — the one write of `consentGroups` anywhere in the platform.\n *\n * The marker must still name this change in its carry phase and the stored\n * declaration must be the one the job started from; otherwise nothing is\n * written and the unit fails. `update`, not a merge, so a removed group's id\n * vanishes with it; an empty declaration deletes the field.\n */\nasync function declare(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n job: ConsentGroupChangeJob,\n owner: string,\n): Promise<ConsentGroupChangeJob> {\n const orgRef = orgDoc(ctx, job.orgId)\n const nowMs = ctx.now()\n const renameOnly = job.plan.renameOnly\n const declared = await save(\n ctx,\n jobRef,\n owner,\n renameOnly\n ? {\n step: 'finish',\n status: 'done',\n phase: 'done',\n declaredAtMs: nowMs,\n finishedAtMs: nowMs,\n units: {},\n }\n : { step: 'catch-up-post', phase: 'rehome', declaredAtMs: nowMs, units: {} },\n async (transaction) => {\n const org = (await transaction.get(orgRef)).data() as Record<string, unknown> | undefined\n const marker = readConsentGroupsChange(org)\n if (!org || marker?.changeId !== job.changeId || marker.phase !== 'carry') {\n throw new Error('The change no longer holds the organization, so the new groups were not saved')\n }\n if (consentGroupsFingerprint(org[CONSENT_GROUPS_FIELD]) !== job.beforeFingerprint) {\n throw new Error('The consent groups changed outside this change, so the new groups were not saved')\n }\n transaction.update(orgRef, {\n [CONSENT_GROUPS_FIELD]: Object.keys(job.after).length ? job.after : FieldValue.delete(),\n [CONSENT_GROUPS_CHANGE_FIELD]: renameOnly\n ? FieldValue.delete()\n : { ...marker, phase: 'rehome', declaredAtMs: nowMs },\n updatedAt: FieldValue.serverTimestamp(),\n })\n },\n )\n const finished = renameOnly ? { ...declared, lease: null } : declared\n if (renameOnly) await jobRef.update({ lease: null })\n for (const line of job.lines) {\n await ctx.log(job.orgId, job.actor, line, { type: 'org', id: job.orgId })\n }\n if (renameOnly) {\n await ctx.log(job.orgId, job.actor, 'Finished a consent group change', {\n type: 'org',\n id: job.orgId,\n })\n }\n return finished\n}\n\n/** The \"Finished\" line: the carries' totals, then each participant's clause. */\nfunction finishedLine(job: ConsentGroupChangeJob): string {\n const copied = job.counts.siteSuppressions + job.counts.topicOptOuts + job.counts.paces\n const sites = job.sitesReceiving.length\n const clauses = [\n `${copied} ${copied === 1 ? 'opt-out' : 'opt-outs'} copied to ${sites} ${sites === 1 ? 'site' : 'sites'}`,\n ]\n for (const pluginId of job.participants) {\n const entry = listPluginConsentGroupParticipants().find((candidate) => candidate.pluginId === pluginId)\n const clause = entry?.participant.summarize?.(job.plugins[pluginId] ?? {})\n if (clause) clauses.push(clause)\n }\n return `Finished a consent group change: ${clauses.join('; ')}`\n}\n\nasync function finish(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n job: ConsentGroupChangeJob,\n owner: string,\n): Promise<ConsentGroupChangeJob> {\n const orgRef = orgDoc(ctx, job.orgId)\n const nowMs = ctx.now()\n const done = await save(\n ctx,\n jobRef,\n owner,\n { status: 'done', phase: 'done', step: 'finish', finishedAtMs: nowMs, units: {} },\n async (transaction) => {\n const marker = readConsentGroupsChange((await transaction.get(orgRef)).data())\n if (marker?.changeId === job.changeId) {\n transaction.update(orgRef, {\n [CONSENT_GROUPS_CHANGE_FIELD]: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n },\n )\n await jobRef.update({ lease: null })\n await ctx.log(job.orgId, job.actor, finishedLine(done), { type: 'org', id: job.orgId })\n return { ...done, lease: null }\n}\n\n/**\n * One unit of work, or nothing when the job is waiting for its sweep.\n */\nasync function advanceOnce(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n job: ConsentGroupChangeJob,\n owner: string,\n deadlineMs: number,\n): Promise<{ job: ConsentGroupChangeJob; idle: boolean }> {\n if (job.step === 'declare') return { job: await declare(ctx, jobRef, job, owner), idle: false }\n if (job.step === 'finish') return { job: await finish(ctx, jobRef, job, owner), idle: false }\n if (job.step === 'sweep-wait') {\n if (ctx.now() < (job.declaredAtMs ?? 0) + CONSENT_GROUP_SWEEP_DELAY_MS) return { job, idle: true }\n return { job: await nextStep(ctx, job.orgId, jobRef, job, owner), idle: false }\n }\n const unit = unitsOf(job).find((candidate) => !job.units[candidate.key]?.done)\n if (!unit) return { job: await nextStep(ctx, job.orgId, jobRef, job, owner), idle: false }\n const before = job.units[unit.key]?.cursor ?? null\n const after = await runUnit(ctx, jobRef, job, owner, unit, deadlineMs)\n const state = after.units[unit.key]\n // A participant that stopped where it started — out of time before its\n // first page — has nothing more to give this invocation.\n return { job: after, idle: unit.kind === 'participant' && !state?.done && (state?.cursor ?? null) === before }\n}\n\n/** Claims the lease, or answers why not. `null` for a job that does not exist. */\nasync function claim(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n owner: string,\n): Promise<{ job: ConsentGroupChangeJob; mine: boolean } | null> {\n return ctx.firestore.runTransaction(async (transaction) => {\n const job = jobFrom(await transaction.get(jobRef))\n if (!job) return null\n const nowMs = ctx.now()\n if (job.status !== 'running') return { job, mine: false }\n if (job.lease && job.lease.untilMs > nowMs && job.lease.owner !== owner) return { job, mine: false }\n const lease = { owner, untilMs: nowMs + CONSENT_GROUP_CHANGE_LEASE_MS }\n transaction.update(jobRef, { lease })\n return { job: { ...job, lease }, mine: true }\n })\n}\n\nasync function release(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n owner: string,\n): Promise<ConsentGroupChangeJob | null> {\n return ctx.firestore.runTransaction(async (transaction) => {\n const job = jobFrom(await transaction.get(jobRef))\n if (!job) return null\n if (job.lease?.owner !== owner) return job\n transaction.update(jobRef, { lease: null })\n return { ...job, lease: null }\n })\n}\n\n/** Counts a failed unit against the job, and says so once when it stalls. */\nasync function recordFailure(\n ctx: Context,\n jobRef: FirebaseFirestore.DocumentReference,\n owner: string,\n error: unknown,\n): Promise<ConsentGroupChangeJob | null> {\n const lastError = sanitizeError(error)\n const outcome = await ctx.firestore.runTransaction(async (transaction) => {\n const job = jobFrom(await transaction.get(jobRef))\n if (!job) return null\n if (job.lease?.owner !== owner) return { job, newlyStalled: false }\n const failures = (job.failures ?? 0) + 1\n const stalled = failures >= CONSENT_GROUP_CHANGE_STALL_AFTER\n const fields = { failures, stalled, lastError, lease: null }\n transaction.update(jobRef, { ...fields, updatedAt: FieldValue.serverTimestamp() })\n return { job: { ...job, ...fields }, newlyStalled: stalled && !job.stalled }\n })\n if (!outcome) return null\n if (outcome.newlyStalled) {\n await ctx.log(\n outcome.job.orgId,\n outcome.job.actor,\n 'A consent group change stopped with an error; it will try again',\n { type: 'org', id: outcome.job.orgId },\n )\n }\n console.error(`[consent-group-change] a unit of ${outcome.job.changeId} failed: ${lastError}`)\n return outcome.job\n}\n\n/**\n * Advances a change until it finishes, waits for its sweep, or reaches\n * `deadlineMs`. Answers the job's status either way — including when another\n * caller holds it, which is not a failure.\n */\nexport async function advanceConsentGroupChange(\n input: {\n orgId: string\n changeId: string\n deadlineMs: number\n /** Who holds the lease; one per invocation. */\n owner?: string\n } & ConsentGroupChangeContext,\n): Promise<{ ok: true; status: ConsentGroupChangeStatusBody } | ConsentGroupChangeRefusal> {\n const ctx = contextOf(input)\n const jobRef = jobDoc(ctx, input.orgId, input.changeId)\n const owner = input.owner ?? `advance:${createResourceUid()}`\n const claimed = await claim(ctx, jobRef, owner)\n if (!claimed) return NOT_FOUND\n let job = claimed.job\n if (!claimed.mine) return { ok: true, status: consentGroupChangeStatus(job, ctx.now()) }\n try {\n while (job.status === 'running' && ctx.now() < input.deadlineMs - DEADLINE_SAFETY_MS) {\n const outcome = await advanceOnce(ctx, jobRef, job, owner, input.deadlineMs)\n job = outcome.job\n if (outcome.idle) break\n }\n } catch (error) {\n if (!(error instanceof LeaseLost)) {\n job = (await recordFailure(ctx, jobRef, owner, error)) ?? job\n return { ok: true, status: consentGroupChangeStatus(job, ctx.now()) }\n }\n job = jobFrom(await jobRef.get()) ?? job\n return { ok: true, status: consentGroupChangeStatus(job, ctx.now()) }\n }\n if (job.status === 'running') job = (await release(ctx, jobRef, owner)) ?? job\n return { ok: true, status: consentGroupChangeStatus(job, ctx.now()) }\n}\n\n/*==========================================\n * CANCELING AND READING\n *=========================================*/\n\n/**\n * Stops a change before it takes effect: clears the marker and leaves every\n * refusal already carried, which only ever honors an opt-out on more sites.\n * Refused once the declaration has flipped — there is nothing left to stop\n * before, and the only way back is another change.\n */\nexport async function cancelConsentGroupChange(\n input: { orgId: string; changeId: string; actor: ConsentGroupChangeActor } & ConsentGroupChangeContext,\n): Promise<{ ok: true; status: ConsentGroupChangeStatusBody } | ConsentGroupChangeRefusal> {\n const ctx = contextOf(input)\n const orgRef = orgDoc(ctx, input.orgId)\n const jobRef = jobDoc(ctx, input.orgId, input.changeId)\n const outcome = await ctx.firestore.runTransaction(async (transaction) => {\n const [orgSnapshot, jobSnapshot] = await Promise.all([transaction.get(orgRef), transaction.get(jobRef)])\n const job = jobFrom(jobSnapshot)\n if (!job) return NOT_FOUND\n if (job.status !== 'running' || job.declaredAtMs) {\n return {\n ok: false as const,\n status: 409 as const,\n body: {\n error:\n job.status === 'running' || job.status === 'done'\n ? 'This change has already taken effect'\n : 'This change is no longer running',\n changeId: job.changeId,\n phase: job.phase,\n },\n }\n }\n const marker = readConsentGroupsChange(orgSnapshot.data())\n if (marker?.changeId === job.changeId) {\n transaction.update(orgRef, {\n [CONSENT_GROUPS_CHANGE_FIELD]: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n const fields = {\n status: 'canceled' as const,\n phase: 'canceled' as const,\n canceledAtMs: ctx.now(),\n lease: null,\n }\n transaction.update(jobRef, { ...fields, updatedAt: FieldValue.serverTimestamp() })\n return { ok: true as const, job: { ...job, ...fields } }\n })\n if (isRefusal(outcome)) return outcome\n const { job } = outcome as { job: ConsentGroupChangeJob }\n await ctx.log(\n input.orgId,\n { uid: input.actor.uid ?? null, email: input.actor.email ?? null },\n 'Canceled a consent group change before it took effect',\n { type: 'org', id: input.orgId },\n )\n return { ok: true, status: consentGroupChangeStatus(job, ctx.now()) }\n}\n\n/** A job's status, or `null` for a change the org never had. */\nexport async function readConsentGroupChangeStatus(\n input: { orgId: string; changeId: string } & ConsentGroupChangeContext,\n): Promise<ConsentGroupChangeStatusBody | null> {\n const ctx = contextOf(input)\n const job = jobFrom(await jobDoc(ctx, input.orgId, input.changeId).get())\n return job ? consentGroupChangeStatus(job, ctx.now()) : null\n}\n\n/*==========================================\n * THE CRON BACKSTOP\n *=========================================*/\n\nexport interface DueConsentGroupChangeReport {\n orgId: string\n changeId: string\n phase: ConsentGroupsChangePhase\n /** What happened on this run. */\n outcome: 'advanced' | 'listed' | 'locked' | 'orphaned' | 'skipped'\n status?: ConsentGroupChangeStatusBody\n error?: string\n}\n\n/**\n * Advances every change in flight, oldest org id first, until `deadlineMs`.\n *\n * A change nobody is watching still finishes: the progress panel is a\n * convenience, and this is the guarantee. A lockdown on the org — or on the\n * member who started the change — pauses it, like every other write the\n * workspace makes. A marker whose job document is gone is a change nothing\n * can finish, and is cleared rather than blocking every change after it.\n */\nexport async function advanceDueConsentGroupChanges(\n input: { deadlineMs: number; dryRun?: boolean; limit?: number } & ConsentGroupChangeContext,\n): Promise<DueConsentGroupChangeReport[]> {\n const ctx = contextOf(input)\n const snapshot = await ctx.firestore\n .collection('orgs')\n .where(`${CONSENT_GROUPS_CHANGE_FIELD}.phase`, 'in', [...RUNNING_PHASES])\n .limit(input.limit ?? 50)\n .get()\n const reports: DueConsentGroupChangeReport[] = []\n for (const org of snapshot.docs) {\n const data = org.data() as Record<string, unknown>\n const marker = readConsentGroupsChange(data)\n if (!marker) continue\n const base = { orgId: org.id, changeId: marker.changeId, phase: marker.phase }\n if (input.dryRun) {\n reports.push({ ...base, outcome: 'listed' })\n continue\n }\n if (ctx.now() >= input.deadlineMs - DEADLINE_SAFETY_MS) {\n reports.push({ ...base, outcome: 'skipped' })\n continue\n }\n try {\n const job = jobFrom(await jobDoc(ctx, org.id, marker.changeId).get())\n if (!job) {\n await orgDoc(ctx, org.id).update({ [CONSENT_GROUPS_CHANGE_FIELD]: FieldValue.delete() })\n console.error(`[consent-group-change] cleared a marker with no job in org ${org.id}`)\n reports.push({ ...base, outcome: 'orphaned' })\n continue\n }\n if (await ctx.lockedOut(data, job.actor?.uid ?? null)) {\n reports.push({ ...base, outcome: 'locked' })\n continue\n }\n const advanced = await advanceConsentGroupChange({\n orgId: org.id,\n changeId: marker.changeId,\n deadlineMs: input.deadlineMs,\n owner: `cron:${createResourceUid()}`,\n firestore: ctx.firestore,\n log: ctx.log,\n now: ctx.now,\n })\n reports.push({\n ...base,\n outcome: 'advanced',\n ...(advanced.ok ? { status: (advanced as { status: ConsentGroupChangeStatusBody }).status } : {}),\n })\n } catch (error) {\n reports.push({ ...base, outcome: 'advanced', error: sanitizeError(error) })\n }\n }\n return reports\n}\n"],"names":["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","CONSENT_GROUPS_FIELD","consentGroupsAwaitConfirmation","readConsentGroups","createResourceUid","DEFAULT_EMAIL_TOPICS","TOPIC_OPT_OUTS_SUBCOLLECTION","resolveMarketingConsentPolicy","isOrgWideMember","listPluginConsentGroupParticipants","previewPluginConsentGroupChange","FieldValue","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","carryConsentGroupRefusals","firebaseAdmin","getLockdownVerdict","logOrgActivity","CONSENT_GROUP_CHANGE_LEASE_MS","CONSENT_GROUP_CHANGE_STALL_AFTER","CATCH_UP_OVERLAP_MS","DEADLINE_SAFETY_MS","RUNNING_PHASES","contextOf","input","firestore","app","log","now","Date","lockedOut","org","uid","intent","NOT_FOUND","ok","status","body","error","LeaseLost","Error","orgDoc","ctx","orgId","collection","doc","jobDoc","changeId","planOf","job","plan","before","after","sanitizeError","message","String","replace","slice","consentGroupChangeStatus","nowMs","declaredAtMs","index","steps","indexOf","step","phase","done","progress","stepIndex","length","stepCount","sweepAtMs","renameOnly","stalled","failures","lastError","leaseUntilMs","lease","untilMs","counts","sitesReceiving","plugins","prepare","expected","groups","marker","raw","current","Array","isArray","siteIds","validation","reserved","Object","keys","errors","isRefusal","value","Boolean","plannedSites","Set","hostIds","values","flatMap","group","siteNamesFor","hosts","snapshots","getAll","map","id","names","forEach","snapshot","get","describeLines","lines","line","hostId","countOf","query","count","Number","data","previewConsentGroupChange","prepared","db","cache","Map","cached","key","pending","set","store","name","suppressions","optOuts","paces","where","carries","Promise","all","carry","toHostId","fromHostId","siteSuppressions","topicOptOuts","membersIn","declaration","find","includes","joining","sort","had","siblings","filter","has","push","inherited","refusals","sibling","pendingHolds","released","releasedHostIds","entries","topic","topicId","changedGroups","was","join","partialAccessMembers","members","member","docs","reach","some","every","policy","forwardPolicyWarning","mode","entry","enforceFromMs","participants","texts","documents","reduce","total","sum","preview","discarded","text","disclosures","capturesDisclosing","estimate","startConsentGroupChange","orgRef","exists","outside","pluginId","jobRef","started","runTransaction","transaction","beforeFingerprint","flows","siteNames","units","actor","email","startedAtMs","carryStartedAtMs","finishedAtMs","canceledAtMs","update","updatedAt","serverTimestamp","create","createdAt","type","unitsOf","sinceMs","_carry","carryIndex","kind","since","jobFrom","save","owner","fields","extra","next","addCounts","base","more","out","isFinite","runUnit","unit","deadlineMs","result","state","cursor","page","written","participant","participantFor","run","dryRun","candidate","nextStep","declare","declared","delete","finished","finishedLine","copied","sites","clauses","clause","summarize","finish","advanceOnce","idle","claim","mine","release","recordFailure","outcome","newlyStalled","console","advanceConsentGroupChange","claimed","cancelConsentGroupChange","orgSnapshot","jobSnapshot","readConsentGroupChangeStatus","advanceDueConsentGroupChanges","limit","reports","advanced"],"mappings":"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"}
@@ -50,10 +50,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
50
50
  * sweep it did not complete.
51
51
  */ import { FieldValue } from "firebase-admin/firestore";
52
52
  import { soloConsentGroup, candidateMatchesDynamicListRule, dynamicListRuleIsEmpty, dynamicListRuleListIds, dynamicListRuleWithoutListReference, dynamicListRuleNeedsContactFacet, dynamicListDimensionsForCrmView, dynamicListPlanningRule, CRM_COLLECTIONS, normalizeCrmViewFilters, dynamicListRuleNeedsEngagement, extractEmailFromFields, isContactLifecycleStage, normalizeContactEmail, normalizeDynamicListRule, personKey, readCampaignIds, readContactCampaignIds, readContactFacet } from "@aglyn/aglyn/server";
53
+ import { consentGroupHoldersInMotion } from "@aglyn/aglyn/app-utils/consent-group-change";
53
54
  import { firebaseAdmin } from "./firebase-admin.js";
54
55
  import { readPersonEngagementByKeys } from "./email-delivery-log.js";
55
56
  import { enrollListMember } from "./list-members.js";
56
- import { consentGroupForSite, orgDataCollectionForHost, scopedToHost } from "./organizations.js";
57
+ import { consentGroupForSite, getOrgForHost, orgDataCollectionForHost, scopedToHost } from "./organizations.js";
57
58
  /**
58
59
  * Documents one sweep run may READ across all sources.
59
60
  *
@@ -359,7 +360,32 @@ const millis = (value)=>{
359
360
  * owner, a stage, a company or a custom field. A rule naming none of them
360
361
  * pays no org read at all, which is the shape the engagement and list
361
362
  * lookups already take.
362
- */ const facetGroupId = dynamicListRuleNeedsContactFacet(planning) ? (await consentGroupForSite(options.hostId)).groupId : null;
363
+ */ let facetGroupId = null;
364
+ if (dynamicListRuleNeedsContactFacet(planning)) {
365
+ var _ref;
366
+ const found = await getOrgForHost(options.hostId).catch(()=>null);
367
+ const org = (_ref = found == null ? void 0 : found.org) != null ? _ref : null;
368
+ /*
369
+ * NOT WHILE THE FACETS MOVE (AGL-3320). A consent group change re-homes
370
+ * this site's facets after its declaration flips, and until it finishes a
371
+ * person's record may still sit under the old key — where this read,
372
+ * keyed by the new group, finds nothing. A rule reads that absence as a
373
+ * non-match, and a non-match is a removal. So the scan reports itself
374
+ * incomplete and reads nothing: the materializer's own rule for a partial
375
+ * view then enrolls nobody new and removes nobody, and the first sweep
376
+ * after the change finishes reads every record where it landed.
377
+ */ if (consentGroupHoldersInMotion(org, options.hostId)) {
378
+ var _options_resume;
379
+ return {
380
+ candidates: [],
381
+ complete: false,
382
+ cursor: (_options_resume = options.resume) != null ? _options_resume : null,
383
+ empty: false,
384
+ read: 0
385
+ };
386
+ }
387
+ facetGroupId = (await consentGroupForSite(options.hostId, org)).groupId;
388
+ }
363
389
  /** Matched people, de-duplicated across silos by their person key. */ const matches = new Map();
364
390
  let read = 0;
365
391
  let cursor = null;
@@ -367,13 +393,13 @@ const millis = (value)=>{
367
393
  const ordered = rule.sources;
368
394
  const resumeIndex = options.resume ? ordered.indexOf(options.resume.source) : -1;
369
395
  scan: for(let index = 0; index < ordered.length; index += 1){
370
- var _ref;
371
- var _options_resume;
396
+ var _ref1;
397
+ var _options_resume1;
372
398
  const source = ordered[index];
373
399
  // A resume skips the sources already finished and re-enters the one that
374
400
  // was interrupted at the id it stopped on.
375
401
  if (resumeIndex >= 0 && index < resumeIndex) continue;
376
- let after = resumeIndex === index ? (_ref = (_options_resume = options.resume) == null ? void 0 : _options_resume.afterId) != null ? _ref : '' : '';
402
+ let after = resumeIndex === index ? (_ref1 = (_options_resume1 = options.resume) == null ? void 0 : _options_resume1.afterId) != null ? _ref1 : '' : '';
377
403
  const base = await sourceQuery(source, options.hostId);
378
404
  for(;;){
379
405
  /*
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dynamic-list-materialize.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Materializes a `kind: 'dynamic'` list's rule into its `members`\n * subcollection (`docs/specs/email-overhaul.md` §3b/§3e).\n *\n * A dynamic list does NOT resolve at send time. It stores a rule, a sweep\n * writes the matching people into the same `members` collection a manual list\n * uses, and the send path keeps reading one deterministic collection. Three\n * reasons, all of them things that have already gone wrong here:\n *\n * 1. The send must not run an unbounded scan — that is what produced an\n * arbitrary, unstable 500 of a site's 3,000 leads.\n * 2. The composer needs a cheap count, not a 5,000-document scan per\n * keystroke.\n * 3. \"Who did this campaign go to\" is a support question and a compliance\n * question. A materialized membership answers it; a rule re-evaluated\n * later does not.\n *\n * ## ⛔ A CEILING NEVER REMOVES A PERSON\n *\n * This function has no quota comparison and must never grow one. Capacity in\n * this product is enforced at the REDUCTION, not at the use: a limit that\n * refuses a person or their data leaks, because the person is already there\n * and dropping them is not a refusal, it is deletion. So a rule that matches\n * more people than a plan's audience band, or than a single send's recipient\n * cap, materializes ALL of them. The ceiling that a large audience meets is\n * `performCampaignSend`'s, which refuses the SEND and states the count it\n * found — a refusal the merchant can see, argue with, and act on, where a\n * trimmed membership is silent and unreproducible.\n *\n * What bounds this function is the per-run SCAN budget below, which is a\n * different thing: it bounds how much work one sweep does, and the cursor it\n * returns resumes the same sweep on the next run. Running out of budget\n * postpones enrollments; it never removes anybody, and it never finishes a\n * sweep it did not complete.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type {\n DocumentReference,\n Query,\n QueryDocumentSnapshot,\n} from 'firebase-admin/firestore'\nimport {\n soloConsentGroup,\n candidateMatchesDynamicListRule,\n dynamicListRuleIsEmpty,\n dynamicListRuleListIds,\n dynamicListRuleWithoutListReference,\n dynamicListRuleNeedsContactFacet,\n dynamicListDimensionsForCrmView,\n dynamicListPlanningRule,\n CRM_COLLECTIONS,\n normalizeCrmViewFilters,\n type DynamicListDimensions,\n dynamicListRuleNeedsEngagement,\n extractEmailFromFields,\n isContactLifecycleStage,\n normalizeContactEmail,\n normalizeDynamicListRule,\n personKey,\n readCampaignIds,\n readContactCampaignIds,\n readContactFacet,\n type ContactCustomValue,\n type DynamicListCandidate,\n type DynamicListRule,\n type DynamicListSource,\n type ResolvedSegmentFilters,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from './firebase-admin'\nimport { readPersonEngagementByKeys } from './email-delivery-log'\nimport { enrollListMember } from './list-members'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n scopedToHost,\n} from './organizations'\n\n/**\n * Documents one sweep run may READ across all sources.\n *\n * A bound on work, not on membership. When a run exhausts it the sweep stops\n * where it is, returns `complete: false` with the cursor it reached, and the\n * next run continues — the shape the import-history sweep already uses. The\n * alternative, an unbounded scan of every silo per list per run, is the\n * expensive-read pattern this codebase refuses.\n *\n * ⚠️ Membership reconciliation is SKIPPED on an incomplete run. A partial\n * scan has only seen part of the population, so anybody it did not reach\n * would look like a non-match — and acting on that would delete real members\n * because a budget ran out.\n */\nexport const DYNAMIC_LIST_SCAN_BUDGET = 5_000\n\n/** Documents read per source page. */\nconst PAGE_SIZE = 500\n\n/**\n * ⚠️ THE DATE FIELDS THIS FILE READS, AND WHO WRITES THEM.\n *\n * `createdAfterMs`/`createdBeforeMs` are applied IN MEMORY against\n * `createdAt`, never as a Firestore `orderBy` or `where`. That distinction is\n * the whole safety property: an `orderBy` on a data field drops every document\n * missing it from the QUERY, invisibly, where an in-memory filter still reads\n * the document and merely decides it does not match. The paging order is\n * `__name__`, the one key every document has.\n *\n * The rule's decision for an undated record is to EXCLUDE it, so the writers\n * were checked rather than assumed — a silo with an unstamped writer would\n * quietly shrink every dated rule:\n *\n * - `leads` — `addHostLead` is the single writer and always stamps it.\n * - `siteMembers` — `membership-register.ts` is the only creator; the other\n * member routes read or update an existing document.\n * - `contacts` — `upsertHostContact`'s create path and the v1 API both stamp.\n * - `formSubmissions` — the submit route is the only creator; the v1 API and\n * the inbox reply route read.\n *\n * A new writer of any of those four must stamp `createdAt` or be added here.\n *\n * ## Campaign membership is read the same way — off the paged document\n *\n * `campaignIds` is applied in memory too, and for a second reason beyond the\n * one above: it is an ARRAY, so a Firestore `array-contains-any` could express\n * only the single-campaign case and would need a composite index the moment it\n * met any other filter in the rule. The membership is already on the document\n * this scan has read, on a submission at the top and on a contact inside the\n * reading holder's facet, so matching it costs nothing the page did not\n * already pay.\n */\n\n/** Where a partial sweep stopped, so the next run resumes rather than restarts. */\nexport interface DynamicListCursor {\n /** The source being scanned when the budget ran out. */\n source: DynamicListSource\n /** The last document id read from it. */\n afterId: string\n}\n\nexport interface MaterializeDynamicListResult {\n /** People the rule matched and that are now enrolled. */\n matched: number\n /** Rows created by this run. */\n enrolled: number\n /** Rule-owned rows removed because the person no longer matches. */\n removed: number\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: DynamicListCursor | null\n /**\n * True when the rule selects nobody by construction (no sources). Reported\n * rather than silently materializing an empty list, because an empty list\n * reads in the composer exactly like a rule that has not run yet.\n */\n empty: boolean\n}\n\n/** What a read-only pass over the silos found. */\nexport interface DynamicListScanResult {\n /** Matched people, de-duplicated across silos by their person key. */\n candidates: DynamicListCandidate[]\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: DynamicListCursor | null\n /**\n * True when the rule selects nobody by construction — no sources, or a\n * `segmentId` naming a segment that no longer exists.\n */\n empty: boolean\n /** Documents read, against the budget. */\n read: number\n}\n\n/**\n * The silo collections, as queries.\n *\n * `contacts` is org-scoped and must go through `scopedToHost` — the Admin SDK\n * does not evaluate rules, so an unfiltered read would let one site's list\n * materialize another site's contacts. The other three are host-owned and\n * carry no `visibleTo` to filter on.\n */\nasync function sourceQuery(\n source: DynamicListSource,\n hostId: string,\n): Promise<Query> {\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId)\n if (source === 'contacts') {\n const ref = await orgDataCollectionForHost(hostId, 'contacts')\n return scopedToHost(ref, hostId)\n }\n return hostRef.collection(source)\n}\n\nconst millis = (value: unknown): number | null => {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: unknown } | null)?.toMillis\n if (typeof toMillis === 'function') {\n const parsed = Number(toMillis.call(value))\n return Number.isFinite(parsed) ? parsed : null\n }\n return null\n}\n\n/**\n * The CRM fields a rule may read, out of ONE holder's facet (AGL-2603).\n *\n * The owner, the stage, the company and the custom values are this holder's\n * business records on a shared row. They are read through `readContactFacet`\n * for the group the sweep runs as — never off the top of the document, where\n * a pre-facet row could still carry somebody's fields, and never out of\n * another group's facet. A field the facet does not carry is left ABSENT on\n * the candidate, which the matcher reads as \"does not match\" for the first\n * three and as \"unset\" for a custom clause; a stage the model does not name\n * is dropped for the reason the normalizer drops one from a rule.\n */\nfunction crmFieldsFromFacet(\n data: Record<string, unknown>,\n groupId: string,\n): Pick<\n DynamicListCandidate,\n 'ownerUid' | 'lifecycleStage' | 'companyId' | 'custom' | 'lastEmailEngagementAtMs'\n> {\n const facet = readContactFacet(data, groupId)\n const custom =\n facet.custom && typeof facet.custom === 'object' && !Array.isArray(facet.custom)\n ? (facet.custom as Record<string, ContactCustomValue>)\n : undefined\n // The campaign engagement stamp (AGL-2616), on the same footing as the\n // owner: this holder's fact about the person, read out of this holder's\n // facet, absent when the webhook has never stamped one.\n const engaged = Number(facet.lastEmailEngagementAtMs ?? 0)\n return {\n ...(typeof facet.ownerUid === 'string' && facet.ownerUid\n ? { ownerUid: facet.ownerUid }\n : {}),\n ...(isContactLifecycleStage(facet.lifecycleStage)\n ? { lifecycleStage: facet.lifecycleStage }\n : {}),\n ...(typeof facet.companyId === 'string' && facet.companyId\n ? { companyId: facet.companyId }\n : {}),\n ...(custom ? { custom } : {}),\n ...(Number.isFinite(engaged) && engaged > 0\n ? { lastEmailEngagementAtMs: engaged }\n : {}),\n }\n}\n\n/**\n * Reads one silo document into the shape the rule matcher understands.\n *\n * @param groupId the consent group whose contact facet the campaign membership\n * and the CRM fields are read from, or `null` when the rule names none of\n * them and no group was resolved. A contact row is shared by every site in\n * the org and its membership, its owner, its stage, its company and its\n * custom values all live inside one holder's facet, so there is no\n * expression here that returns another holder's filing.\n */\nfunction toCandidate(\n source: DynamicListSource,\n doc: QueryDocumentSnapshot,\n groupId: string | null,\n): DynamicListCandidate | null {\n const data = doc.data() as Record<string, unknown>\n const email =\n source === 'formSubmissions'\n ? // A form does not guarantee a canonical email field, so this is the\n // same best-effort extraction the submissions route already uses to\n // decide whether a submission produces a contact at all.\n extractEmailFromFields(data['fields'] as Record<string, unknown>)\n : normalizeContactEmail(data['email'])\n if (!email) return null\n const createdAtMs = millis(data['createdAt'])\n return {\n silo: source,\n email,\n // `siteMembers` stores `displayName`; the rest store `name`. Reading only\n // `name` is what left every member campaign's merge tags blank.\n name:\n (typeof data['name'] === 'string' && data['name']) ||\n (typeof data['displayName'] === 'string' && data['displayName']) ||\n undefined,\n createdAtMs,\n ...(source === 'contacts'\n ? {\n tags: (data['tags'] as string[]) ?? [],\n sources: (data['sources'] as DynamicListCandidate['sources']) ?? {},\n ordersCount: Number(data['ordersCount'] ?? 0),\n ltvCents: Number(data['ltvCents'] ?? 0),\n lastPurchaseAtMs: millis(data['lastPurchaseAtMs']),\n // Inside the holder's facet, never the top of the document. A rule\n // with no facet clause resolves no group and reads nothing here,\n // which is what keeps the group lookup an opt-in cost.\n ...(groupId\n ? {\n campaignIds: readContactCampaignIds(data, groupId),\n ...crmFieldsFromFacet(data, groupId),\n }\n : {}),\n }\n : {}),\n ...(source === 'formSubmissions'\n ? {\n formName: String(data['formName'] ?? ''),\n // At the TOP of a submission, because a submission belongs to one\n // site — the same shape every other host resource carries it in.\n campaignIds: readCampaignIds(data),\n }\n : {}),\n }\n}\n\n/*==========================================\n * ENRICHMENT — the two dimensions the scan cannot read off a silo row.\n *\n * Engagement lives on `emailDeliveries/{personKey}` and list membership on\n * `orgs/{orgId}/lists/{listId}/members/{personKey}`. Both describe an ADDRESS\n * rather than a contact, a lead or a submission, so neither is in the\n * document the sweep just paged.\n *\n * Three properties, and each of them is a thing that could otherwise go\n * wrong here:\n *\n * 1. **KEYED reads, never queries.** `getAll` over document references. No\n * `where`, no `orderBy`, so no composite index, nothing that can be\n * truncated by a `limit`, and — the failure this codebase has hit\n * nineteen times — nothing that silently drops a document for missing\n * the field being ordered on.\n * 2. **Opt-in.** A rule with no engagement clause and no list reference\n * pays nothing at all. The cost belongs to the rules that ask for it,\n * not to every sweep.\n * 3. **Charged to the SAME scan budget.** Every enrichment read is counted\n * in `read`, so a rule that asks for engagement fills the budget faster\n * and its sweep simply takes more runs. The budget and the resume cursor\n * keep working exactly as they did; what changes is how much of the\n * budget one page costs.\n *=========================================*/\n\n/** What a rule needs looked up, resolved once per scan rather than per page. */\ninterface EnrichmentPlan {\n engagement: boolean\n /** `listId` → that list's `members` collection. */\n listMembers: Map<string, FirebaseFirestore.CollectionReference>\n}\n\n/**\n * Resolves what a rule needs enriched.\n *\n * A named list that no longer exists resolves to a collection with no\n * documents, which reads as \"nobody is a member\" — the same direction a\n * deleted `segmentId` takes, and the safe one: an `inListIds` naming a\n * deleted list selects nobody rather than everybody, and a `notInListIds`\n * naming one excludes nobody rather than the whole audience.\n */\nasync function planEnrichment(\n rule: DynamicListRule,\n hostId: string,\n): Promise<EnrichmentPlan> {\n const listIds = dynamicListRuleListIds(rule)\n const listMembers = new Map<string, FirebaseFirestore.CollectionReference>()\n if (listIds.length) {\n const lists = await orgDataCollectionForHost(hostId, 'lists')\n for (const listId of listIds) {\n listMembers.set(listId, lists.doc(listId).collection('members'))\n }\n }\n return { engagement: dynamicListRuleNeedsEngagement(rule), listMembers }\n}\n\n/** True when nothing needs looking up, so the page loop can skip the work. */\nfunction planIsEmpty(plan: EnrichmentPlan): boolean {\n return !plan.engagement && plan.listMembers.size === 0\n}\n\n/**\n * Fills the enrichment fields on one page's candidates.\n *\n * Keys are de-duplicated first: two silos can produce the same person on one\n * page, and looking them up twice would spend budget on an answer already in\n * hand.\n *\n * @returns how many documents were read, for the caller's budget.\n */\nasync function enrichCandidates(\n candidates: DynamicListCandidate[],\n plan: EnrichmentPlan,\n): Promise<number> {\n if (!candidates.length || planIsEmpty(plan)) return 0\n const keyed = new Map<string, DynamicListCandidate[]>()\n for (const candidate of candidates) {\n const key = personKey(candidate.email)\n if (!key) continue\n const held = keyed.get(key)\n if (held) held.push(candidate)\n else keyed.set(key, [candidate])\n }\n const keys = [...keyed.keys()]\n if (!keys.length) return 0\n const firestore = firebaseAdmin.app().firestore()\n let read = 0\n\n if (plan.engagement) {\n // The scan's own Firestore handle, passed rather than defaulted: the\n // rollup lives in another module with its own default, and two handles in\n // one sweep is two things a caller has to configure to reach one store.\n const engagement = await readPersonEngagementByKeys(keys, firestore)\n read += keys.length\n for (const [key, rows] of keyed) {\n const found = engagement.get(key)\n for (const candidate of rows) {\n candidate.lastOpenedAtMs = found?.lastOpenedAtMs ?? null\n candidate.lastClickedAtMs = found?.lastClickedAtMs ?? null\n }\n }\n }\n\n if (plan.listMembers.size) {\n // No pre-seeding of `listIds`. A candidate found on no list keeps the\n // field absent, and the matcher reads absent as \"on no list\" — see\n // `candidateMatchesDynamicListRule` for why that direction is the safe\n // one. Writing an empty array first would be a line saying the same thing\n // the matcher already says.\n for (const [listId, members] of plan.listMembers) {\n let snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n try {\n snapshots = await firestore.getAll(\n ...keys.map((key) => members.doc(key)),\n )\n } catch (error) {\n // A failed membership lookup leaves every candidate reading as a\n // non-member of this list. That keeps an `inListIds` rule from\n // enrolling people it could not confirm, and it is the direction a\n // reader can recover from — the next sweep asks again.\n console.error('[dynamic-list] list membership lookup failed', listId, error)\n }\n read += keys.length\n for (const snapshot of snapshots) {\n if (!snapshot?.exists) continue\n for (const candidate of keyed.get(snapshot.id) ?? []) {\n candidate.listIds = [...(candidate.listIds ?? []), listId]\n }\n }\n }\n }\n\n return read\n}\n\n/**\n * WHO A RULE SELECTS, without writing anything.\n *\n * The scan and the enrollment used to be one function, so \"who would this\n * match\" could only be answered by materializing it. That is the wrong shape\n * for two callers that now exist: a merchant asking to see an audience before\n * committing to it, and a fixed list using the filters to FIND people to add.\n * Neither may write memberships as a side effect of a question.\n *\n * One scanner rather than two. Every subtlety here is a place a second\n * implementation would drift — the in-memory date filter (an `orderBy` on a\n * data field would DROP undated documents rather than merely not matching\n * them), the `__name__` paging order, `scopedToHost` on `contacts` and on\n * nothing else, and a deleted `segmentId` narrowing to nobody rather than\n * widening to everybody. `materializeDynamicList` reads this and then enrolls.\n *\n * NOTHING here writes, and nothing here compares a count against a limit.\n */\nexport async function collectDynamicListCandidates(options: {\n hostId: string\n rule: unknown\n resume?: DynamicListCursor | null\n nowMs?: number\n scanBudget?: number\n}): Promise<DynamicListScanResult> {\n const rule: DynamicListRule = normalizeDynamicListRule(options.rule)\n const nowMs = options.nowMs ?? Date.now()\n const budget = options.scanBudget ?? DYNAMIC_LIST_SCAN_BUDGET\n if (dynamicListRuleIsEmpty(rule)) {\n return { candidates: [], complete: true, cursor: null, empty: true, read: 0 }\n }\n\n // A saved segment's filters, resolved ONCE rather than per candidate.\n let segment: ResolvedSegmentFilters | null = null\n if (rule.segmentId) {\n const segments = await orgDataCollectionForHost(\n options.hostId,\n 'contactSegments',\n )\n const snapshot = await segments.doc(rule.segmentId).get()\n // A deleted segment narrows to nothing rather than widening to everybody:\n // a rule whose filter vanished must not start matching the whole org.\n if (!snapshot.exists) {\n return {\n candidates: [],\n complete: true,\n cursor: null,\n empty: true,\n read: 0,\n }\n }\n segment = {\n tags: (snapshot.get('tags') as string[]) ?? [],\n sources: (snapshot.get('sources') as ResolvedSegmentFilters['sources']) ?? [],\n }\n }\n\n /*\n * A saved VIEW's filters, resolved ONCE into this language (AGL-2617).\n *\n * The same two leans the segment takes, for the same reason: a view that\n * was deleted narrows to nothing, and so does one whose clauses this\n * language cannot carry — `dynamicListDimensionsForCrmView` names them\n * rather than dropping them, and dropping one would make the audience\n * wider than the view the merchant picked. The picker offers only views\n * that translate whole, so the second case is a view edited after the\n * rule named it, and a view of another section — a list of companies is\n * not an audience of people — reads the same way.\n */\n let view: DynamicListDimensions | null = null\n if (rule.viewId) {\n const views = await orgDataCollectionForHost(\n options.hostId,\n CRM_COLLECTIONS.views,\n )\n const snapshot = await views.doc(rule.viewId).get()\n const translation =\n snapshot.exists && snapshot.get('section') === 'contacts'\n ? dynamicListDimensionsForCrmView(\n normalizeCrmViewFilters(snapshot.get('filters')),\n )\n : null\n if (!translation || translation.unsupported.length) {\n return {\n candidates: [],\n complete: true,\n cursor: null,\n empty: true,\n read: 0,\n }\n }\n view = translation.dimensions\n }\n\n /*\n * Resolved once for the whole scan: it names collections, and re-resolving\n * the org for every page would be a read per page for an unchanging answer.\n * Planned with the view's dimensions beside the rule's: a view naming an\n * owner needs the facet exactly as a rule naming one does.\n */\n const planning = dynamicListPlanningRule(rule, view)\n const enrichment = await planEnrichment(planning, options.hostId)\n\n /*\n * THE HOLDER whose contact facet a campaign or CRM clause is read from.\n *\n * The same group the writers use — the sites declared to be one sender, or\n * this site alone — because a facet written under one group id and read\n * under another is a membership that silently matches nobody. Resolved once,\n * and only for a rule that names something the facet holds: a campaign, an\n * owner, a stage, a company or a custom field. A rule naming none of them\n * pays no org read at all, which is the shape the engagement and list\n * lookups already take.\n */\n const facetGroupId = dynamicListRuleNeedsContactFacet(planning)\n ? (await consentGroupForSite(options.hostId)).groupId\n : null\n\n /** Matched people, de-duplicated across silos by their person key. */\n const matches = new Map<string, DynamicListCandidate>()\n let read = 0\n let cursor: DynamicListCursor | null = null\n let complete = true\n\n const ordered = rule.sources\n const resumeIndex = options.resume\n ? ordered.indexOf(options.resume.source)\n : -1\n\n scan: for (let index = 0; index < ordered.length; index += 1) {\n const source = ordered[index] as DynamicListSource\n // A resume skips the sources already finished and re-enters the one that\n // was interrupted at the id it stopped on.\n if (resumeIndex >= 0 && index < resumeIndex) continue\n let after = resumeIndex === index ? options.resume?.afterId ?? '' : ''\n const base = await sourceQuery(source, options.hostId)\n for (;;) {\n /*\n * Ordered by document id, and paged with a `startAfter` cursor.\n *\n * `orderBy(FieldPath.documentId())` rather than a data field on\n * purpose: an `orderBy` on a field DROPS every document missing it, and\n * these four collections have writers old enough that no data field is\n * guaranteed on every row. The id is on all of them, so this is a total\n * order that cannot silently omit a person — which is the whole point\n * of the exercise, since the defect being fixed is an unordered\n * `limit()` returning an arbitrary subset.\n */\n let page = base\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(PAGE_SIZE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n /*\n * The page is read into candidates FIRST, then enriched, then matched.\n *\n * A per-document match would mean a per-document enrichment lookup, and\n * the whole reason enrichment is affordable is that one page's people\n * are fetched in one `getAll`. Only candidates are enriched — a\n * document with no usable address is dropped by `toCandidate` before it\n * can cost a lookup.\n */\n const pageCandidates: DynamicListCandidate[] = []\n for (const doc of snapshot.docs) {\n read += 1\n after = doc.id\n const candidate = toCandidate(source, doc, facetGroupId)\n if (candidate) pageCandidates.push(candidate)\n }\n read += await enrichCandidates(pageCandidates, enrichment)\n for (const candidate of pageCandidates) {\n if (\n !candidateMatchesDynamicListRule(candidate, rule, {\n segment,\n view,\n nowMs,\n })\n ) {\n continue\n }\n const key = personKey(candidate.email)\n if (key && !matches.has(key)) matches.set(key, candidate)\n }\n if (read >= budget) {\n complete = false\n cursor = { source, afterId: after }\n break scan\n }\n if (snapshot.size < PAGE_SIZE) break\n }\n }\n\n\n return {\n candidates: [...matches.values()],\n complete,\n cursor,\n empty: false,\n read,\n }\n}\n\n/**\n * Re-evaluates one dynamic list and writes the result into its members.\n *\n * @param listRef `orgs/{orgId}/lists/{listId}` — the caller has proved it\n * exists and that it is `kind: 'dynamic'`.\n * @param hostId the site whose silos the rule draws from, and the scope the\n * org-owned contacts read is narrowed to.\n * @param resume a cursor from a previous incomplete run.\n */\nexport async function materializeDynamicList(options: {\n listRef: DocumentReference\n hostId: string\n rule: unknown\n resume?: DynamicListCursor | null\n nowMs?: number\n /**\n * The scan budget. Passed by NOTHING in production — it exists so the suite\n * can drive a sweep into an exhausted budget and require that it still\n * removes nobody. A guarantee that only holds at the production budget was\n * never that guarantee.\n */\n scanBudget?: number\n}): Promise<MaterializeDynamicListResult> {\n /*\n * The rule cannot refer to the list it materializes — see\n * `dynamicListRuleWithoutListReference` for the oscillation that produces,\n * and note that half of its beats are DELETIONS of rows this sweep created.\n * Stripped here rather than at authoring time because the rule outlives any\n * one form: an API caller, an import and a copied list would each need to\n * remember, and the sweep is the one place all of them arrive.\n */\n const rule = dynamicListRuleWithoutListReference(\n normalizeDynamicListRule(options.rule),\n options.listRef.id,\n )\n const scan = await collectDynamicListCandidates({\n hostId: options.hostId,\n rule,\n ...(options.resume ? { resume: options.resume } : {}),\n ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}),\n ...(options.scanBudget !== undefined\n ? { scanBudget: options.scanBudget }\n : {}),\n })\n if (scan.empty) {\n return {\n matched: 0,\n enrolled: 0,\n removed: 0,\n complete: true,\n cursor: null,\n empty: true,\n }\n }\n const { complete, cursor } = scan\n /*\n * Re-keyed by person rather than carried as a Map across the seam.\n *\n * Reconciliation below asks `matches.has(key)` about a stored row's id and\n * about its address, so it needs the same key the scan de-duplicated on —\n * computed by the same function, from the same addresses.\n */\n const matches = new Map<string, DynamicListCandidate>()\n for (const candidate of scan.candidates) {\n const key = personKey(candidate.email)\n if (key) matches.set(key, candidate)\n }\n\n let enrolled = 0\n for (const candidate of matches.values()) {\n const result = await enrollListMember({\n listRef: options.listRef,\n // A group of one, and it costs nothing: the materializer passes NO\n // consent (see below), so the group decides only which site is recorded\n // as having captured the row.\n group: soloConsentGroup(options.hostId),\n email: candidate.email,\n ...(candidate.name ? { name: candidate.name } : {}),\n source: `rule:${candidate.silo}`,\n via: 'rule',\n /*\n * ⛔ NO CONSENT IS PASSED, EVER.\n *\n * Matching a rule is not consenting to anything — the person is here\n * because they bought something, filled in a form or hold an account,\n * and none of those is an opt-in. Stamping a basis from a rule match\n * would let any merchant manufacture consent for their whole contact\n * list by writing a rule that selects it, which is precisely the\n * inference the consent arc refused to make. Their real basis, if they\n * have one, is on their own record and the send-time join reads it\n * there.\n */\n })\n // A refusal is not a materialization failure: an unusable address and a\n // person whose row records a refusal are both simply not on this list,\n // and a rule that selected them does not overrule either.\n if (result.enrolled && result.created) enrolled += 1\n }\n\n /*\n * Reconciliation is allowed only for a run that saw the WHOLE population:\n * one that started at the beginning AND finished.\n *\n * `complete` alone is not enough, and that is the trap. A run that RESUMES\n * from a cursor finishes the tail of the scan and reports `complete: true`\n * having examined only the tail — so its match set is the remainder, and\n * reconciling against it would delete every member found by the earlier\n * runs of the same sweep. That is a quota drop wearing the costume of a\n * membership change: nothing about it looks like a limit, and it removes\n * people for no reason but how the work was chunked.\n *\n * The cost is that a list whose population exceeds one run's scan budget\n * never reconciles, so somebody who stops matching stays enrolled until a\n * sweep fits in one run. That is the right way round: an over-full list is\n * a wrong count, which is recoverable, and a deleted enrollment destroys\n * the record that says the person asked to be there, which is not.\n */\n const sawWholePopulation = complete && !options.resume\n let removed = 0\n if (sawWholePopulation) {\n /*\n * Reconciliation.\n *\n * Only rows this materializer created are eligible (`via === 'rule'`). A\n * row somebody enrolled by hand, or that an automation's `enrollList`\n * step wrote, stays on a dynamic list forever: it was put there by a\n * decision, and a rule that does not happen to select that person is not\n * a decision to remove them. Rows written before `via` existed carry no\n * `via` at all and are therefore never eligible either, which is the\n * conservative direction for a field introduced after the data.\n *\n * This is the one removal in this file and it is a MEMBERSHIP change —\n * the person stopped matching the rule. It is not a capacity decision and\n * there is no count in the condition.\n */\n const existing = await options.listRef\n .collection('members')\n .where('via', '==', 'rule')\n .get()\n for (const doc of existing.docs) {\n if (matches.has(doc.id)) continue\n // A row adopted under a legacy id is keyed by neither `personKey` nor\n // anything this sweep computed, so it is matched by ADDRESS as well\n // before being removed. Getting that wrong un-enrolls a person who\n // still matches the rule.\n const email = normalizeContactEmail(doc.get('email'))\n const key = email ? personKey(email) : null\n if (key && matches.has(key)) continue\n await doc.ref.delete()\n removed += 1\n }\n }\n\n await options.listRef.set(\n {\n kind: 'dynamic',\n // The count the composer reads instead of scanning. Written only by a\n // run that saw everybody, for the same reason reconciliation is: a\n // partial or resumed run measured a fraction, and publishing that as\n // the list's size would under-report the audience in the one place a\n // merchant checks it before sending.\n ...(sawWholePopulation ? { memberCount: matches.size } : {}),\n lastEvaluatedAt: FieldValue.serverTimestamp(),\n ...(cursor ? { evaluationCursor: cursor } : { evaluationCursor: null }),\n },\n { merge: true },\n )\n\n return {\n matched: matches.size,\n enrolled,\n removed,\n complete,\n cursor,\n empty: false,\n }\n}\n"],"names":["FieldValue","soloConsentGroup","candidateMatchesDynamicListRule","dynamicListRuleIsEmpty","dynamicListRuleListIds","dynamicListRuleWithoutListReference","dynamicListRuleNeedsContactFacet","dynamicListDimensionsForCrmView","dynamicListPlanningRule","CRM_COLLECTIONS","normalizeCrmViewFilters","dynamicListRuleNeedsEngagement","extractEmailFromFields","isContactLifecycleStage","normalizeContactEmail","normalizeDynamicListRule","personKey","readCampaignIds","readContactCampaignIds","readContactFacet","firebaseAdmin","readPersonEngagementByKeys","enrollListMember","consentGroupForSite","orgDataCollectionForHost","scopedToHost","DYNAMIC_LIST_SCAN_BUDGET","PAGE_SIZE","sourceQuery","source","hostId","hostRef","app","firestore","collection","doc","ref","millis","value","Number","isFinite","toMillis","parsed","call","crmFieldsFromFacet","data","groupId","facet","custom","Array","isArray","undefined","engaged","lastEmailEngagementAtMs","ownerUid","lifecycleStage","companyId","toCandidate","email","createdAtMs","silo","name","tags","sources","ordersCount","ltvCents","lastPurchaseAtMs","campaignIds","formName","String","planEnrichment","rule","listIds","listMembers","Map","length","lists","listId","set","engagement","planIsEmpty","plan","size","enrichCandidates","candidates","keyed","candidate","key","held","get","push","keys","read","rows","found","lastOpenedAtMs","lastClickedAtMs","members","snapshots","getAll","map","error","console","snapshot","exists","id","collectDynamicListCandidates","options","nowMs","Date","now","budget","scanBudget","complete","cursor","empty","segment","segmentId","segments","view","viewId","views","translation","unsupported","dimensions","planning","enrichment","facetGroupId","matches","ordered","resumeIndex","resume","indexOf","scan","index","after","afterId","base","page","orderBy","FieldPath","documentId","limit","startAfter","pageCandidates","docs","has","values","materializeDynamicList","listRef","matched","enrolled","removed","result","group","via","created","sawWholePopulation","existing","where","delete","kind","memberCount","lastEvaluatedAt","serverTimestamp","evaluationCursor","merge"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dynamic-list-materialize.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Materializes a `kind: 'dynamic'` list's rule into its `members`\n * subcollection (`docs/specs/email-overhaul.md` §3b/§3e).\n *\n * A dynamic list does NOT resolve at send time. It stores a rule, a sweep\n * writes the matching people into the same `members` collection a manual list\n * uses, and the send path keeps reading one deterministic collection. Three\n * reasons, all of them things that have already gone wrong here:\n *\n * 1. The send must not run an unbounded scan — that is what produced an\n * arbitrary, unstable 500 of a site's 3,000 leads.\n * 2. The composer needs a cheap count, not a 5,000-document scan per\n * keystroke.\n * 3. \"Who did this campaign go to\" is a support question and a compliance\n * question. A materialized membership answers it; a rule re-evaluated\n * later does not.\n *\n * ## ⛔ A CEILING NEVER REMOVES A PERSON\n *\n * This function has no quota comparison and must never grow one. Capacity in\n * this product is enforced at the REDUCTION, not at the use: a limit that\n * refuses a person or their data leaks, because the person is already there\n * and dropping them is not a refusal, it is deletion. So a rule that matches\n * more people than a plan's audience band, or than a single send's recipient\n * cap, materializes ALL of them. The ceiling that a large audience meets is\n * `performCampaignSend`'s, which refuses the SEND and states the count it\n * found — a refusal the merchant can see, argue with, and act on, where a\n * trimmed membership is silent and unreproducible.\n *\n * What bounds this function is the per-run SCAN budget below, which is a\n * different thing: it bounds how much work one sweep does, and the cursor it\n * returns resumes the same sweep on the next run. Running out of budget\n * postpones enrollments; it never removes anybody, and it never finishes a\n * sweep it did not complete.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type {\n DocumentReference,\n Query,\n QueryDocumentSnapshot,\n} from 'firebase-admin/firestore'\nimport {\n soloConsentGroup,\n candidateMatchesDynamicListRule,\n dynamicListRuleIsEmpty,\n dynamicListRuleListIds,\n dynamicListRuleWithoutListReference,\n dynamicListRuleNeedsContactFacet,\n dynamicListDimensionsForCrmView,\n dynamicListPlanningRule,\n CRM_COLLECTIONS,\n normalizeCrmViewFilters,\n type DynamicListDimensions,\n dynamicListRuleNeedsEngagement,\n extractEmailFromFields,\n isContactLifecycleStage,\n normalizeContactEmail,\n normalizeDynamicListRule,\n personKey,\n readCampaignIds,\n readContactCampaignIds,\n readContactFacet,\n type ContactCustomValue,\n type DynamicListCandidate,\n type DynamicListRule,\n type DynamicListSource,\n type ResolvedSegmentFilters,\n} from '@aglyn/aglyn/server'\nimport { consentGroupHoldersInMotion } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { firebaseAdmin } from './firebase-admin'\nimport { readPersonEngagementByKeys } from './email-delivery-log'\nimport { enrollListMember } from './list-members'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n scopedToHost,\n} from './organizations'\n\n/**\n * Documents one sweep run may READ across all sources.\n *\n * A bound on work, not on membership. When a run exhausts it the sweep stops\n * where it is, returns `complete: false` with the cursor it reached, and the\n * next run continues — the shape the import-history sweep already uses. The\n * alternative, an unbounded scan of every silo per list per run, is the\n * expensive-read pattern this codebase refuses.\n *\n * ⚠️ Membership reconciliation is SKIPPED on an incomplete run. A partial\n * scan has only seen part of the population, so anybody it did not reach\n * would look like a non-match — and acting on that would delete real members\n * because a budget ran out.\n */\nexport const DYNAMIC_LIST_SCAN_BUDGET = 5_000\n\n/** Documents read per source page. */\nconst PAGE_SIZE = 500\n\n/**\n * ⚠️ THE DATE FIELDS THIS FILE READS, AND WHO WRITES THEM.\n *\n * `createdAfterMs`/`createdBeforeMs` are applied IN MEMORY against\n * `createdAt`, never as a Firestore `orderBy` or `where`. That distinction is\n * the whole safety property: an `orderBy` on a data field drops every document\n * missing it from the QUERY, invisibly, where an in-memory filter still reads\n * the document and merely decides it does not match. The paging order is\n * `__name__`, the one key every document has.\n *\n * The rule's decision for an undated record is to EXCLUDE it, so the writers\n * were checked rather than assumed — a silo with an unstamped writer would\n * quietly shrink every dated rule:\n *\n * - `leads` — `addHostLead` is the single writer and always stamps it.\n * - `siteMembers` — `membership-register.ts` is the only creator; the other\n * member routes read or update an existing document.\n * - `contacts` — `upsertHostContact`'s create path and the v1 API both stamp.\n * - `formSubmissions` — the submit route is the only creator; the v1 API and\n * the inbox reply route read.\n *\n * A new writer of any of those four must stamp `createdAt` or be added here.\n *\n * ## Campaign membership is read the same way — off the paged document\n *\n * `campaignIds` is applied in memory too, and for a second reason beyond the\n * one above: it is an ARRAY, so a Firestore `array-contains-any` could express\n * only the single-campaign case and would need a composite index the moment it\n * met any other filter in the rule. The membership is already on the document\n * this scan has read, on a submission at the top and on a contact inside the\n * reading holder's facet, so matching it costs nothing the page did not\n * already pay.\n */\n\n/** Where a partial sweep stopped, so the next run resumes rather than restarts. */\nexport interface DynamicListCursor {\n /** The source being scanned when the budget ran out. */\n source: DynamicListSource\n /** The last document id read from it. */\n afterId: string\n}\n\nexport interface MaterializeDynamicListResult {\n /** People the rule matched and that are now enrolled. */\n matched: number\n /** Rows created by this run. */\n enrolled: number\n /** Rule-owned rows removed because the person no longer matches. */\n removed: number\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: DynamicListCursor | null\n /**\n * True when the rule selects nobody by construction (no sources). Reported\n * rather than silently materializing an empty list, because an empty list\n * reads in the composer exactly like a rule that has not run yet.\n */\n empty: boolean\n}\n\n/** What a read-only pass over the silos found. */\nexport interface DynamicListScanResult {\n /** Matched people, de-duplicated across silos by their person key. */\n candidates: DynamicListCandidate[]\n /** False when the scan budget ran out — `cursor` says where to resume. */\n complete: boolean\n cursor: DynamicListCursor | null\n /**\n * True when the rule selects nobody by construction — no sources, or a\n * `segmentId` naming a segment that no longer exists.\n */\n empty: boolean\n /** Documents read, against the budget. */\n read: number\n}\n\n/**\n * The silo collections, as queries.\n *\n * `contacts` is org-scoped and must go through `scopedToHost` — the Admin SDK\n * does not evaluate rules, so an unfiltered read would let one site's list\n * materialize another site's contacts. The other three are host-owned and\n * carry no `visibleTo` to filter on.\n */\nasync function sourceQuery(\n source: DynamicListSource,\n hostId: string,\n): Promise<Query> {\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId)\n if (source === 'contacts') {\n const ref = await orgDataCollectionForHost(hostId, 'contacts')\n return scopedToHost(ref, hostId)\n }\n return hostRef.collection(source)\n}\n\nconst millis = (value: unknown): number | null => {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: unknown } | null)?.toMillis\n if (typeof toMillis === 'function') {\n const parsed = Number(toMillis.call(value))\n return Number.isFinite(parsed) ? parsed : null\n }\n return null\n}\n\n/**\n * The CRM fields a rule may read, out of ONE holder's facet (AGL-2603).\n *\n * The owner, the stage, the company and the custom values are this holder's\n * business records on a shared row. They are read through `readContactFacet`\n * for the group the sweep runs as — never off the top of the document, where\n * a pre-facet row could still carry somebody's fields, and never out of\n * another group's facet. A field the facet does not carry is left ABSENT on\n * the candidate, which the matcher reads as \"does not match\" for the first\n * three and as \"unset\" for a custom clause; a stage the model does not name\n * is dropped for the reason the normalizer drops one from a rule.\n */\nfunction crmFieldsFromFacet(\n data: Record<string, unknown>,\n groupId: string,\n): Pick<\n DynamicListCandidate,\n 'ownerUid' | 'lifecycleStage' | 'companyId' | 'custom' | 'lastEmailEngagementAtMs'\n> {\n const facet = readContactFacet(data, groupId)\n const custom =\n facet.custom && typeof facet.custom === 'object' && !Array.isArray(facet.custom)\n ? (facet.custom as Record<string, ContactCustomValue>)\n : undefined\n // The campaign engagement stamp (AGL-2616), on the same footing as the\n // owner: this holder's fact about the person, read out of this holder's\n // facet, absent when the webhook has never stamped one.\n const engaged = Number(facet.lastEmailEngagementAtMs ?? 0)\n return {\n ...(typeof facet.ownerUid === 'string' && facet.ownerUid\n ? { ownerUid: facet.ownerUid }\n : {}),\n ...(isContactLifecycleStage(facet.lifecycleStage)\n ? { lifecycleStage: facet.lifecycleStage }\n : {}),\n ...(typeof facet.companyId === 'string' && facet.companyId\n ? { companyId: facet.companyId }\n : {}),\n ...(custom ? { custom } : {}),\n ...(Number.isFinite(engaged) && engaged > 0\n ? { lastEmailEngagementAtMs: engaged }\n : {}),\n }\n}\n\n/**\n * Reads one silo document into the shape the rule matcher understands.\n *\n * @param groupId the consent group whose contact facet the campaign membership\n * and the CRM fields are read from, or `null` when the rule names none of\n * them and no group was resolved. A contact row is shared by every site in\n * the org and its membership, its owner, its stage, its company and its\n * custom values all live inside one holder's facet, so there is no\n * expression here that returns another holder's filing.\n */\nfunction toCandidate(\n source: DynamicListSource,\n doc: QueryDocumentSnapshot,\n groupId: string | null,\n): DynamicListCandidate | null {\n const data = doc.data() as Record<string, unknown>\n const email =\n source === 'formSubmissions'\n ? // A form does not guarantee a canonical email field, so this is the\n // same best-effort extraction the submissions route already uses to\n // decide whether a submission produces a contact at all.\n extractEmailFromFields(data['fields'] as Record<string, unknown>)\n : normalizeContactEmail(data['email'])\n if (!email) return null\n const createdAtMs = millis(data['createdAt'])\n return {\n silo: source,\n email,\n // `siteMembers` stores `displayName`; the rest store `name`. Reading only\n // `name` is what left every member campaign's merge tags blank.\n name:\n (typeof data['name'] === 'string' && data['name']) ||\n (typeof data['displayName'] === 'string' && data['displayName']) ||\n undefined,\n createdAtMs,\n ...(source === 'contacts'\n ? {\n tags: (data['tags'] as string[]) ?? [],\n sources: (data['sources'] as DynamicListCandidate['sources']) ?? {},\n ordersCount: Number(data['ordersCount'] ?? 0),\n ltvCents: Number(data['ltvCents'] ?? 0),\n lastPurchaseAtMs: millis(data['lastPurchaseAtMs']),\n // Inside the holder's facet, never the top of the document. A rule\n // with no facet clause resolves no group and reads nothing here,\n // which is what keeps the group lookup an opt-in cost.\n ...(groupId\n ? {\n campaignIds: readContactCampaignIds(data, groupId),\n ...crmFieldsFromFacet(data, groupId),\n }\n : {}),\n }\n : {}),\n ...(source === 'formSubmissions'\n ? {\n formName: String(data['formName'] ?? ''),\n // At the TOP of a submission, because a submission belongs to one\n // site — the same shape every other host resource carries it in.\n campaignIds: readCampaignIds(data),\n }\n : {}),\n }\n}\n\n/*==========================================\n * ENRICHMENT — the two dimensions the scan cannot read off a silo row.\n *\n * Engagement lives on `emailDeliveries/{personKey}` and list membership on\n * `orgs/{orgId}/lists/{listId}/members/{personKey}`. Both describe an ADDRESS\n * rather than a contact, a lead or a submission, so neither is in the\n * document the sweep just paged.\n *\n * Three properties, and each of them is a thing that could otherwise go\n * wrong here:\n *\n * 1. **KEYED reads, never queries.** `getAll` over document references. No\n * `where`, no `orderBy`, so no composite index, nothing that can be\n * truncated by a `limit`, and — the failure this codebase has hit\n * nineteen times — nothing that silently drops a document for missing\n * the field being ordered on.\n * 2. **Opt-in.** A rule with no engagement clause and no list reference\n * pays nothing at all. The cost belongs to the rules that ask for it,\n * not to every sweep.\n * 3. **Charged to the SAME scan budget.** Every enrichment read is counted\n * in `read`, so a rule that asks for engagement fills the budget faster\n * and its sweep simply takes more runs. The budget and the resume cursor\n * keep working exactly as they did; what changes is how much of the\n * budget one page costs.\n *=========================================*/\n\n/** What a rule needs looked up, resolved once per scan rather than per page. */\ninterface EnrichmentPlan {\n engagement: boolean\n /** `listId` → that list's `members` collection. */\n listMembers: Map<string, FirebaseFirestore.CollectionReference>\n}\n\n/**\n * Resolves what a rule needs enriched.\n *\n * A named list that no longer exists resolves to a collection with no\n * documents, which reads as \"nobody is a member\" — the same direction a\n * deleted `segmentId` takes, and the safe one: an `inListIds` naming a\n * deleted list selects nobody rather than everybody, and a `notInListIds`\n * naming one excludes nobody rather than the whole audience.\n */\nasync function planEnrichment(\n rule: DynamicListRule,\n hostId: string,\n): Promise<EnrichmentPlan> {\n const listIds = dynamicListRuleListIds(rule)\n const listMembers = new Map<string, FirebaseFirestore.CollectionReference>()\n if (listIds.length) {\n const lists = await orgDataCollectionForHost(hostId, 'lists')\n for (const listId of listIds) {\n listMembers.set(listId, lists.doc(listId).collection('members'))\n }\n }\n return { engagement: dynamicListRuleNeedsEngagement(rule), listMembers }\n}\n\n/** True when nothing needs looking up, so the page loop can skip the work. */\nfunction planIsEmpty(plan: EnrichmentPlan): boolean {\n return !plan.engagement && plan.listMembers.size === 0\n}\n\n/**\n * Fills the enrichment fields on one page's candidates.\n *\n * Keys are de-duplicated first: two silos can produce the same person on one\n * page, and looking them up twice would spend budget on an answer already in\n * hand.\n *\n * @returns how many documents were read, for the caller's budget.\n */\nasync function enrichCandidates(\n candidates: DynamicListCandidate[],\n plan: EnrichmentPlan,\n): Promise<number> {\n if (!candidates.length || planIsEmpty(plan)) return 0\n const keyed = new Map<string, DynamicListCandidate[]>()\n for (const candidate of candidates) {\n const key = personKey(candidate.email)\n if (!key) continue\n const held = keyed.get(key)\n if (held) held.push(candidate)\n else keyed.set(key, [candidate])\n }\n const keys = [...keyed.keys()]\n if (!keys.length) return 0\n const firestore = firebaseAdmin.app().firestore()\n let read = 0\n\n if (plan.engagement) {\n // The scan's own Firestore handle, passed rather than defaulted: the\n // rollup lives in another module with its own default, and two handles in\n // one sweep is two things a caller has to configure to reach one store.\n const engagement = await readPersonEngagementByKeys(keys, firestore)\n read += keys.length\n for (const [key, rows] of keyed) {\n const found = engagement.get(key)\n for (const candidate of rows) {\n candidate.lastOpenedAtMs = found?.lastOpenedAtMs ?? null\n candidate.lastClickedAtMs = found?.lastClickedAtMs ?? null\n }\n }\n }\n\n if (plan.listMembers.size) {\n // No pre-seeding of `listIds`. A candidate found on no list keeps the\n // field absent, and the matcher reads absent as \"on no list\" — see\n // `candidateMatchesDynamicListRule` for why that direction is the safe\n // one. Writing an empty array first would be a line saying the same thing\n // the matcher already says.\n for (const [listId, members] of plan.listMembers) {\n let snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n try {\n snapshots = await firestore.getAll(\n ...keys.map((key) => members.doc(key)),\n )\n } catch (error) {\n // A failed membership lookup leaves every candidate reading as a\n // non-member of this list. That keeps an `inListIds` rule from\n // enrolling people it could not confirm, and it is the direction a\n // reader can recover from — the next sweep asks again.\n console.error('[dynamic-list] list membership lookup failed', listId, error)\n }\n read += keys.length\n for (const snapshot of snapshots) {\n if (!snapshot?.exists) continue\n for (const candidate of keyed.get(snapshot.id) ?? []) {\n candidate.listIds = [...(candidate.listIds ?? []), listId]\n }\n }\n }\n }\n\n return read\n}\n\n/**\n * WHO A RULE SELECTS, without writing anything.\n *\n * The scan and the enrollment used to be one function, so \"who would this\n * match\" could only be answered by materializing it. That is the wrong shape\n * for two callers that now exist: a merchant asking to see an audience before\n * committing to it, and a fixed list using the filters to FIND people to add.\n * Neither may write memberships as a side effect of a question.\n *\n * One scanner rather than two. Every subtlety here is a place a second\n * implementation would drift — the in-memory date filter (an `orderBy` on a\n * data field would DROP undated documents rather than merely not matching\n * them), the `__name__` paging order, `scopedToHost` on `contacts` and on\n * nothing else, and a deleted `segmentId` narrowing to nobody rather than\n * widening to everybody. `materializeDynamicList` reads this and then enrolls.\n *\n * NOTHING here writes, and nothing here compares a count against a limit.\n */\nexport async function collectDynamicListCandidates(options: {\n hostId: string\n rule: unknown\n resume?: DynamicListCursor | null\n nowMs?: number\n scanBudget?: number\n}): Promise<DynamicListScanResult> {\n const rule: DynamicListRule = normalizeDynamicListRule(options.rule)\n const nowMs = options.nowMs ?? Date.now()\n const budget = options.scanBudget ?? DYNAMIC_LIST_SCAN_BUDGET\n if (dynamicListRuleIsEmpty(rule)) {\n return { candidates: [], complete: true, cursor: null, empty: true, read: 0 }\n }\n\n // A saved segment's filters, resolved ONCE rather than per candidate.\n let segment: ResolvedSegmentFilters | null = null\n if (rule.segmentId) {\n const segments = await orgDataCollectionForHost(\n options.hostId,\n 'contactSegments',\n )\n const snapshot = await segments.doc(rule.segmentId).get()\n // A deleted segment narrows to nothing rather than widening to everybody:\n // a rule whose filter vanished must not start matching the whole org.\n if (!snapshot.exists) {\n return {\n candidates: [],\n complete: true,\n cursor: null,\n empty: true,\n read: 0,\n }\n }\n segment = {\n tags: (snapshot.get('tags') as string[]) ?? [],\n sources: (snapshot.get('sources') as ResolvedSegmentFilters['sources']) ?? [],\n }\n }\n\n /*\n * A saved VIEW's filters, resolved ONCE into this language (AGL-2617).\n *\n * The same two leans the segment takes, for the same reason: a view that\n * was deleted narrows to nothing, and so does one whose clauses this\n * language cannot carry — `dynamicListDimensionsForCrmView` names them\n * rather than dropping them, and dropping one would make the audience\n * wider than the view the merchant picked. The picker offers only views\n * that translate whole, so the second case is a view edited after the\n * rule named it, and a view of another section — a list of companies is\n * not an audience of people — reads the same way.\n */\n let view: DynamicListDimensions | null = null\n if (rule.viewId) {\n const views = await orgDataCollectionForHost(\n options.hostId,\n CRM_COLLECTIONS.views,\n )\n const snapshot = await views.doc(rule.viewId).get()\n const translation =\n snapshot.exists && snapshot.get('section') === 'contacts'\n ? dynamicListDimensionsForCrmView(\n normalizeCrmViewFilters(snapshot.get('filters')),\n )\n : null\n if (!translation || translation.unsupported.length) {\n return {\n candidates: [],\n complete: true,\n cursor: null,\n empty: true,\n read: 0,\n }\n }\n view = translation.dimensions\n }\n\n /*\n * Resolved once for the whole scan: it names collections, and re-resolving\n * the org for every page would be a read per page for an unchanging answer.\n * Planned with the view's dimensions beside the rule's: a view naming an\n * owner needs the facet exactly as a rule naming one does.\n */\n const planning = dynamicListPlanningRule(rule, view)\n const enrichment = await planEnrichment(planning, options.hostId)\n\n /*\n * THE HOLDER whose contact facet a campaign or CRM clause is read from.\n *\n * The same group the writers use — the sites declared to be one sender, or\n * this site alone — because a facet written under one group id and read\n * under another is a membership that silently matches nobody. Resolved once,\n * and only for a rule that names something the facet holds: a campaign, an\n * owner, a stage, a company or a custom field. A rule naming none of them\n * pays no org read at all, which is the shape the engagement and list\n * lookups already take.\n */\n let facetGroupId: string | null = null\n if (dynamicListRuleNeedsContactFacet(planning)) {\n const found = await getOrgForHost(options.hostId).catch(() => null)\n const org = (found?.org as Record<string, unknown> | undefined) ?? null\n /*\n * NOT WHILE THE FACETS MOVE (AGL-3320). A consent group change re-homes\n * this site's facets after its declaration flips, and until it finishes a\n * person's record may still sit under the old key — where this read,\n * keyed by the new group, finds nothing. A rule reads that absence as a\n * non-match, and a non-match is a removal. So the scan reports itself\n * incomplete and reads nothing: the materializer's own rule for a partial\n * view then enrolls nobody new and removes nobody, and the first sweep\n * after the change finishes reads every record where it landed.\n */\n if (consentGroupHoldersInMotion(org, options.hostId)) {\n return {\n candidates: [],\n complete: false,\n cursor: options.resume ?? null,\n empty: false,\n read: 0,\n }\n }\n facetGroupId = (await consentGroupForSite(options.hostId, org)).groupId\n }\n\n /** Matched people, de-duplicated across silos by their person key. */\n const matches = new Map<string, DynamicListCandidate>()\n let read = 0\n let cursor: DynamicListCursor | null = null\n let complete = true\n\n const ordered = rule.sources\n const resumeIndex = options.resume\n ? ordered.indexOf(options.resume.source)\n : -1\n\n scan: for (let index = 0; index < ordered.length; index += 1) {\n const source = ordered[index] as DynamicListSource\n // A resume skips the sources already finished and re-enters the one that\n // was interrupted at the id it stopped on.\n if (resumeIndex >= 0 && index < resumeIndex) continue\n let after = resumeIndex === index ? options.resume?.afterId ?? '' : ''\n const base = await sourceQuery(source, options.hostId)\n for (;;) {\n /*\n * Ordered by document id, and paged with a `startAfter` cursor.\n *\n * `orderBy(FieldPath.documentId())` rather than a data field on\n * purpose: an `orderBy` on a field DROPS every document missing it, and\n * these four collections have writers old enough that no data field is\n * guaranteed on every row. The id is on all of them, so this is a total\n * order that cannot silently omit a person — which is the whole point\n * of the exercise, since the defect being fixed is an unordered\n * `limit()` returning an arbitrary subset.\n */\n let page = base\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(PAGE_SIZE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n /*\n * The page is read into candidates FIRST, then enriched, then matched.\n *\n * A per-document match would mean a per-document enrichment lookup, and\n * the whole reason enrichment is affordable is that one page's people\n * are fetched in one `getAll`. Only candidates are enriched — a\n * document with no usable address is dropped by `toCandidate` before it\n * can cost a lookup.\n */\n const pageCandidates: DynamicListCandidate[] = []\n for (const doc of snapshot.docs) {\n read += 1\n after = doc.id\n const candidate = toCandidate(source, doc, facetGroupId)\n if (candidate) pageCandidates.push(candidate)\n }\n read += await enrichCandidates(pageCandidates, enrichment)\n for (const candidate of pageCandidates) {\n if (\n !candidateMatchesDynamicListRule(candidate, rule, {\n segment,\n view,\n nowMs,\n })\n ) {\n continue\n }\n const key = personKey(candidate.email)\n if (key && !matches.has(key)) matches.set(key, candidate)\n }\n if (read >= budget) {\n complete = false\n cursor = { source, afterId: after }\n break scan\n }\n if (snapshot.size < PAGE_SIZE) break\n }\n }\n\n\n return {\n candidates: [...matches.values()],\n complete,\n cursor,\n empty: false,\n read,\n }\n}\n\n/**\n * Re-evaluates one dynamic list and writes the result into its members.\n *\n * @param listRef `orgs/{orgId}/lists/{listId}` — the caller has proved it\n * exists and that it is `kind: 'dynamic'`.\n * @param hostId the site whose silos the rule draws from, and the scope the\n * org-owned contacts read is narrowed to.\n * @param resume a cursor from a previous incomplete run.\n */\nexport async function materializeDynamicList(options: {\n listRef: DocumentReference\n hostId: string\n rule: unknown\n resume?: DynamicListCursor | null\n nowMs?: number\n /**\n * The scan budget. Passed by NOTHING in production — it exists so the suite\n * can drive a sweep into an exhausted budget and require that it still\n * removes nobody. A guarantee that only holds at the production budget was\n * never that guarantee.\n */\n scanBudget?: number\n}): Promise<MaterializeDynamicListResult> {\n /*\n * The rule cannot refer to the list it materializes — see\n * `dynamicListRuleWithoutListReference` for the oscillation that produces,\n * and note that half of its beats are DELETIONS of rows this sweep created.\n * Stripped here rather than at authoring time because the rule outlives any\n * one form: an API caller, an import and a copied list would each need to\n * remember, and the sweep is the one place all of them arrive.\n */\n const rule = dynamicListRuleWithoutListReference(\n normalizeDynamicListRule(options.rule),\n options.listRef.id,\n )\n const scan = await collectDynamicListCandidates({\n hostId: options.hostId,\n rule,\n ...(options.resume ? { resume: options.resume } : {}),\n ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}),\n ...(options.scanBudget !== undefined\n ? { scanBudget: options.scanBudget }\n : {}),\n })\n if (scan.empty) {\n return {\n matched: 0,\n enrolled: 0,\n removed: 0,\n complete: true,\n cursor: null,\n empty: true,\n }\n }\n const { complete, cursor } = scan\n /*\n * Re-keyed by person rather than carried as a Map across the seam.\n *\n * Reconciliation below asks `matches.has(key)` about a stored row's id and\n * about its address, so it needs the same key the scan de-duplicated on —\n * computed by the same function, from the same addresses.\n */\n const matches = new Map<string, DynamicListCandidate>()\n for (const candidate of scan.candidates) {\n const key = personKey(candidate.email)\n if (key) matches.set(key, candidate)\n }\n\n let enrolled = 0\n for (const candidate of matches.values()) {\n const result = await enrollListMember({\n listRef: options.listRef,\n // A group of one, and it costs nothing: the materializer passes NO\n // consent (see below), so the group decides only which site is recorded\n // as having captured the row.\n group: soloConsentGroup(options.hostId),\n email: candidate.email,\n ...(candidate.name ? { name: candidate.name } : {}),\n source: `rule:${candidate.silo}`,\n via: 'rule',\n /*\n * ⛔ NO CONSENT IS PASSED, EVER.\n *\n * Matching a rule is not consenting to anything — the person is here\n * because they bought something, filled in a form or hold an account,\n * and none of those is an opt-in. Stamping a basis from a rule match\n * would let any merchant manufacture consent for their whole contact\n * list by writing a rule that selects it, which is precisely the\n * inference the consent arc refused to make. Their real basis, if they\n * have one, is on their own record and the send-time join reads it\n * there.\n */\n })\n // A refusal is not a materialization failure: an unusable address and a\n // person whose row records a refusal are both simply not on this list,\n // and a rule that selected them does not overrule either.\n if (result.enrolled && result.created) enrolled += 1\n }\n\n /*\n * Reconciliation is allowed only for a run that saw the WHOLE population:\n * one that started at the beginning AND finished.\n *\n * `complete` alone is not enough, and that is the trap. A run that RESUMES\n * from a cursor finishes the tail of the scan and reports `complete: true`\n * having examined only the tail — so its match set is the remainder, and\n * reconciling against it would delete every member found by the earlier\n * runs of the same sweep. That is a quota drop wearing the costume of a\n * membership change: nothing about it looks like a limit, and it removes\n * people for no reason but how the work was chunked.\n *\n * The cost is that a list whose population exceeds one run's scan budget\n * never reconciles, so somebody who stops matching stays enrolled until a\n * sweep fits in one run. That is the right way round: an over-full list is\n * a wrong count, which is recoverable, and a deleted enrollment destroys\n * the record that says the person asked to be there, which is not.\n */\n const sawWholePopulation = complete && !options.resume\n let removed = 0\n if (sawWholePopulation) {\n /*\n * Reconciliation.\n *\n * Only rows this materializer created are eligible (`via === 'rule'`). A\n * row somebody enrolled by hand, or that an automation's `enrollList`\n * step wrote, stays on a dynamic list forever: it was put there by a\n * decision, and a rule that does not happen to select that person is not\n * a decision to remove them. Rows written before `via` existed carry no\n * `via` at all and are therefore never eligible either, which is the\n * conservative direction for a field introduced after the data.\n *\n * This is the one removal in this file and it is a MEMBERSHIP change —\n * the person stopped matching the rule. It is not a capacity decision and\n * there is no count in the condition.\n */\n const existing = await options.listRef\n .collection('members')\n .where('via', '==', 'rule')\n .get()\n for (const doc of existing.docs) {\n if (matches.has(doc.id)) continue\n // A row adopted under a legacy id is keyed by neither `personKey` nor\n // anything this sweep computed, so it is matched by ADDRESS as well\n // before being removed. Getting that wrong un-enrolls a person who\n // still matches the rule.\n const email = normalizeContactEmail(doc.get('email'))\n const key = email ? personKey(email) : null\n if (key && matches.has(key)) continue\n await doc.ref.delete()\n removed += 1\n }\n }\n\n await options.listRef.set(\n {\n kind: 'dynamic',\n // The count the composer reads instead of scanning. Written only by a\n // run that saw everybody, for the same reason reconciliation is: a\n // partial or resumed run measured a fraction, and publishing that as\n // the list's size would under-report the audience in the one place a\n // merchant checks it before sending.\n ...(sawWholePopulation ? { memberCount: matches.size } : {}),\n lastEvaluatedAt: FieldValue.serverTimestamp(),\n ...(cursor ? { evaluationCursor: cursor } : { evaluationCursor: null }),\n },\n { merge: true },\n )\n\n return {\n matched: matches.size,\n enrolled,\n removed,\n complete,\n cursor,\n empty: false,\n }\n}\n"],"names":["FieldValue","soloConsentGroup","candidateMatchesDynamicListRule","dynamicListRuleIsEmpty","dynamicListRuleListIds","dynamicListRuleWithoutListReference","dynamicListRuleNeedsContactFacet","dynamicListDimensionsForCrmView","dynamicListPlanningRule","CRM_COLLECTIONS","normalizeCrmViewFilters","dynamicListRuleNeedsEngagement","extractEmailFromFields","isContactLifecycleStage","normalizeContactEmail","normalizeDynamicListRule","personKey","readCampaignIds","readContactCampaignIds","readContactFacet","consentGroupHoldersInMotion","firebaseAdmin","readPersonEngagementByKeys","enrollListMember","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","scopedToHost","DYNAMIC_LIST_SCAN_BUDGET","PAGE_SIZE","sourceQuery","source","hostId","hostRef","app","firestore","collection","doc","ref","millis","value","Number","isFinite","toMillis","parsed","call","crmFieldsFromFacet","data","groupId","facet","custom","Array","isArray","undefined","engaged","lastEmailEngagementAtMs","ownerUid","lifecycleStage","companyId","toCandidate","email","createdAtMs","silo","name","tags","sources","ordersCount","ltvCents","lastPurchaseAtMs","campaignIds","formName","String","planEnrichment","rule","listIds","listMembers","Map","length","lists","listId","set","engagement","planIsEmpty","plan","size","enrichCandidates","candidates","keyed","candidate","key","held","get","push","keys","read","rows","found","lastOpenedAtMs","lastClickedAtMs","members","snapshots","getAll","map","error","console","snapshot","exists","id","collectDynamicListCandidates","options","nowMs","Date","now","budget","scanBudget","complete","cursor","empty","segment","segmentId","segments","view","viewId","views","translation","unsupported","dimensions","planning","enrichment","facetGroupId","catch","org","resume","matches","ordered","resumeIndex","indexOf","scan","index","after","afterId","base","page","orderBy","FieldPath","documentId","limit","startAfter","pageCandidates","docs","has","values","materializeDynamicList","listRef","matched","enrolled","removed","result","group","via","created","sawWholePopulation","existing","where","delete","kind","memberCount","lastEvaluatedAt","serverTimestamp","evaluationCursor","merge"],"mappings":"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"}
@@ -17,6 +17,7 @@
17
17
  import { type AccountAddressSet, type AccountAddressSource } from './account-addresses';
18
18
  import { type PluginUserErasureReport } from '@aglyn/aglyn/plugin-manager/plugin-user-erasure';
19
19
  import { type PluginOrgErasureReport } from '@aglyn/aglyn/plugin-manager/plugin-org-erasure';
20
+ import { type ConsentGroupSiteHold } from '@aglyn/aglyn/app-utils/consent-groups';
20
21
  import { type SendingDomainDisposition, type TeardownSendingDomainDriver } from './sending-domain-debt';
21
22
  /** The reversible hold before a requested erasure is executed (AGL-485). */
22
23
  export declare const ERASURE_HOLD_MS: number;
@@ -46,6 +47,26 @@ export interface EraseHostOptions {
46
47
  * not fail or stall because a vendor is unreachable.
47
48
  */
48
49
  tearDownSendingDomain?: TeardownSendingDomainDriver | null;
50
+ /**
51
+ * Erase the site even while its consent group still needs it (AGL-3320).
52
+ *
53
+ * Only the organization's own erasure passes this: it takes every site and
54
+ * the declaration with them, so no sibling is left to read the refusals.
55
+ * Every other caller is refused with {@link SiteInConsentGroupError} for a
56
+ * site that is in a declared group or in a change still running — its
57
+ * opt-outs are read by its siblings, and erasing them would start mailing
58
+ * people who said no.
59
+ */
60
+ allowInConsentGroup?: boolean;
61
+ }
62
+ /**
63
+ * {@link eraseHost} refused a site its consent group still needs (AGL-3320).
64
+ * Nothing was erased; the site leaves its group, and is deleted after.
65
+ */
66
+ export declare class SiteInConsentGroupError extends Error {
67
+ readonly hostId: string;
68
+ readonly hold: ConsentGroupSiteHold;
69
+ constructor(hostId: string, hold: ConsentGroupSiteHold);
49
70
  }
50
71
  export interface EraseHostResult {
51
72
  /** What became of the site's dedicated sending domain, if it had one. */
@@ -28,6 +28,9 @@ import { removeOrgMember } from "./organizations.js";
28
28
  import { runPluginUserErasers } from "@aglyn/aglyn/plugin-manager/plugin-user-erasure";
29
29
  import { runPluginOrgErasers } from "@aglyn/aglyn/plugin-manager/plugin-org-erasure";
30
30
  import { isBillingSubscription } from "@aglyn/aglyn/server";
31
+ // The module path rather than the barrel: this module's specs substitute the
32
+ // barrel, and which sites a consent group still needs is the rule itself.
33
+ import { consentGroupSiteHold } from "@aglyn/aglyn/app-utils/consent-groups";
31
34
  import { listPluginOrgCollections } from "@aglyn/aglyn/plugin-manager/plugin-host-collections";
32
35
  import { readOrgBilling } from "./org-billing.js";
33
36
  import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./sending-domain-debt.js";
@@ -163,6 +166,17 @@ import { disposeHostSendingDomain, readSendingDomainTeardownByLabel } from "./se
163
166
  }
164
167
  return erased;
165
168
  }
169
+ /**
170
+ * {@link eraseHost} refused a site its consent group still needs (AGL-3320).
171
+ * Nothing was erased; the site leaves its group, and is deleted after.
172
+ */ export class SiteInConsentGroupError extends Error {
173
+ constructor(hostId, hold){
174
+ super(hold.reason === 'grouped' ? `Site ${hostId} is in the consent group "${hold.name}"; remove it from the group before deleting it` : `Site ${hostId} is in a consent group change that has not finished`);
175
+ this.name = 'SiteInConsentGroupError';
176
+ this.hostId = hostId;
177
+ this.hold = hold;
178
+ }
179
+ }
166
180
  export async function eraseHost(hostId, options = {}) {
167
181
  var _hostSnapshot_get;
168
182
  const firestore = firebaseAdmin.app().firestore();
@@ -170,6 +184,20 @@ export async function eraseHost(hostId, options = {}) {
170
184
  const hostSnapshot = await hostRef.get();
171
185
  const orgId = hostSnapshot.get('orgId');
172
186
  /*==========================================
187
+ * A SITE ITS CONSENT GROUP STILL NEEDS IS NOT ERASED (AGL-3320).
188
+ *
189
+ * Asked before anything is destroyed. A grouped site's opt-outs are read by
190
+ * every site in its group, so erasing them would let its siblings mail the
191
+ * people who unsubscribed here, and would leave the declaration naming a
192
+ * site that no longer exists. The site leaves the group first — the change
193
+ * that removes it carries its refusals to the sites that stay.
194
+ *=========================================*/ if (orgId && options.allowInConsentGroup !== true) {
195
+ var _orgSnapshot_data;
196
+ const orgSnapshot = await firestore.collection('orgs').doc(orgId).get();
197
+ const hold = consentGroupSiteHold((_orgSnapshot_data = orgSnapshot.data()) != null ? _orgSnapshot_data : null, hostId);
198
+ if (hold) throw new SiteInConsentGroupError(hostId, hold);
199
+ }
200
+ /*==========================================
173
201
  * THE SENDING DOMAIN, READ FIRST AND RELEASED LAST.
174
202
  *
175
203
  * Read here because the pinned label lives on the host document and the
@@ -998,8 +1026,11 @@ export async function eraseHost(hostId, options = {}) {
998
1026
  };
999
1027
  for (const host of hosts.docs){
1000
1028
  if (!dryRun) {
1029
+ // The whole organization goes, declaration and all, so no site is
1030
+ // left to read the refusals a grouped site holds.
1001
1031
  const erased = await eraseHost(host.id, {
1002
- tearDownSendingDomain
1032
+ tearDownSendingDomain,
1033
+ allowInConsentGroup: true
1003
1034
  });
1004
1035
  if (erased.sendingDomain === 'released') {
1005
1036
  progress.sendingDomains.released += 1;