@aglyn/aglyn 1.0.0-beta.196 → 1.0.0-beta.197
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/consent-group-change.d.ts +519 -0
- package/src/lib/app-utils/consent-group-change.js +671 -0
- package/src/lib/app-utils/consent-group-change.js.map +1 -0
- package/src/lib/app-utils/consent-groups.d.ts +106 -8
- package/src/lib/app-utils/consent-groups.js +163 -8
- package/src/lib/app-utils/consent-groups.js.map +1 -1
- package/src/lib/app-utils/contact-merge.js +37 -4
- package/src/lib/app-utils/contact-merge.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +7 -7
- package/src/lib/app-utils/docs-help.generated.js +13 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +48 -34
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/health-report.js +13 -0
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/marketing-consent.d.ts +59 -0
- package/src/lib/app-utils/marketing-consent.js +86 -0
- package/src/lib/app-utils/marketing-consent.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +22 -3
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/plugin-manager/plugin-consent-group-change.d.ts +116 -0
- package/src/lib/plugin-manager/plugin-consent-group-change.js +113 -0
- package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 * CHANGING WHICH SITES ARE ONE SENDER (AGL-3320) — the pure half.\n *\n * `consent-groups.ts` reads a declaration; this is what a change to one\n * means. It validates the next declaration an admin submits, decides what has\n * to move for the sites to stop or start being one sender, and words the\n * result — and it does all of that without a read, so the console's review\n * step, the route's refusal and the executor's plan are one computation.\n *\n * ## Why a change is a plan and not a write\n *\n * A declaration is read at send time, so flipping it is instant — and that is\n * exactly the problem. Two facts are stored per SITE and read across the\n * CURRENT group: a refusal (a suppression row, a topic opt-out, a pace, a\n * declined consent) and a holder's business record (the CRM facet keyed by\n * the group). When sites stop being one sender, each stops reading the\n * other's refusals; when they become one, the records they kept apart must\n * become one. The flip itself moves neither, so every change is:\n *\n * 1. CARRY the refusals onto the sites that are about to stop reading them,\n * in BOTH directions — the person who left one site of a sender was told\n * the opt-out covers all of them, so the site leaving keeps what it\n * honored and the sites staying keep what the leaver honored;\n * 2. FLIP the declaration;\n * 3. RE-HOME the holder records keyed by the old group ids.\n *\n * {@link planConsentGroupChange} says which pairs carry and which holder keys\n * move or split; the executor in `libs/tenant/data/admin` performs it.\n *\n * ## What the planner never touches\n *\n * Grants. A grant is written forward, one entry per disclosed site, and is\n * evidence of what a person was told, so a change never copies, re-keys or\n * revokes one — see `consent-groups.ts`, \"Pooling applies FORWARD only\".\n */\n\nimport {\n consentGroupDisclosure,\n CONSENT_GROUPS_FIELD,\n MAX_CONSENT_GROUP_HOSTS,\n readConsentGroups,\n type StoredConsentGroup,\n} from './consent-groups'\n\n/**\n * The org field that marks a change in flight:\n * {@link ConsentGroupsChangeMarker}. Authoritative — the job document holds\n * the detail, and this says whether there is a job at all.\n *\n * On the org document, beside the declaration it guards, so the transaction\n * that starts a change and the one that flips the declaration read one\n * document. Server-owned: denied to every client by the rules.\n */\nexport const CONSENT_GROUPS_CHANGE_FIELD = 'consentGroupsChange'\n\n/** `orgs/{orgId}/consentGroupChanges/{changeId}` — one job per change. */\nexport const CONSENT_GROUP_CHANGES_COLLECTION = 'consentGroupChanges'\n\n/**\n * The longest group name a declaration may carry.\n *\n * The name is rendered on every signup form of the group, inside a sentence,\n * so it is a brand and not a description.\n */\nexport const CONSENT_GROUP_NAME_MAX = 80\n\n/**\n * How long after the flip the sweep waits before its final pass.\n *\n * A long job — a campaign batch, a dynamic list, an outreach tick — resolves\n * the group once per batch and runs for at most five minutes, so a write it\n * made against the OLD declaration can land after the flip. Six minutes is\n * past the longest of them.\n */\nexport const CONSENT_GROUP_SWEEP_DELAY_MS = 6 * 60_000\n\n/**\n * What every minted group id starts with.\n *\n * A group id is a holder key beside the site ids a solo site is keyed by, so\n * the two must never meet. Firestore's automatic ids — which every site id is\n * — are alphanumeric, and an underscore cannot occur in one.\n */\nexport const CONSENT_GROUP_ID_PREFIX = 'cg_'\n\n/** Every group of a declaration, by id — what `readConsentGroups` answers. */\nexport type ConsentGroupDeclaration = Record<string, StoredConsentGroup>\n\n/** One group of the next declaration as the editor submits it. */\nexport interface ConsentGroupDraft {\n /** The id of a group that exists; absent for a new group. */\n id?: string | null\n name: string\n hostIds: string[]\n}\n\n/**\n * Why a submitted declaration was refused. Every code names something the\n * admin can fix in the editor; `groupIndex` points at the draft it is about,\n * and `hostId` at the site.\n */\nexport type ConsentGroupValidationCode =\n /** Not a list of `{ name, hostIds }` objects. */\n | 'malformed'\n | 'name-empty'\n | 'name-too-long'\n /** Two groups with the same name, ignoring case. */\n | 'name-duplicate'\n | 'too-few-sites'\n | 'too-many-sites'\n /** A site this organization does not own. */\n | 'unknown-site'\n | 'site-in-two-groups'\n /** An id no group of the current declaration has. */\n | 'unknown-group'\n /** One id on two drafts. */\n | 'duplicate-group'\n /**\n * A kept id whose sites are ALL new. Keeping the id would carry the old\n * group's records onto a set of sites none of which kept them; a new group\n * is what that is.\n */\n | 'group-replaced'\n /** The declaration submitted is the one in force. */\n | 'no-change'\n\nexport interface ConsentGroupValidationError {\n code: ConsentGroupValidationCode\n groupIndex?: number\n hostId?: string\n}\n\nexport type ConsentGroupValidation =\n | { ok: true; after: ConsentGroupDeclaration }\n | { ok: false; errors: ConsentGroupValidationError[] }\n\n/**\n * The sites an organization owns, from its `hosts` map, sorted.\n *\n * The map is `{ [hostId]: true }`; an array is read too, so a hand-built\n * fixture and the stored shape agree.\n */\nexport function orgSiteIds(\n org: Record<string, unknown> | null | undefined,\n): string[] {\n const hosts = (org ?? {})['hosts']\n const ids = Array.isArray(hosts)\n ? hosts.map((id) => String(id ?? ''))\n : hosts && typeof hosts === 'object'\n ? Object.entries(hosts as Record<string, unknown>)\n .filter(([, value]) => value !== false && value != null)\n .map(([id]) => id)\n : []\n return [...new Set(ids.map((id) => id.trim()).filter(Boolean))].sort()\n}\n\n/** The raw declaration's ids that `readConsentGroups` drops, sorted. */\nexport function discardedConsentGroupIds(\n org: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []\n const usable = readConsentGroups(org)\n return Object.keys(raw as Record<string, unknown>)\n .filter((id) => !(id in usable))\n .sort()\n}\n\nconst BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz'\n\n/** Uniform in [0, 1), from the platform's CSPRNG where there is one. */\nfunction defaultRandom(): number {\n const crypto = (globalThis as { crypto?: { getRandomValues?: <T>(array: T) => T } })\n .crypto\n if (crypto?.getRandomValues) {\n const word = crypto.getRandomValues(new Uint32Array(1))[0]\n return word / 2 ** 32\n }\n return Math.random()\n}\n\n/**\n * A fresh group id: {@link CONSENT_GROUP_ID_PREFIX} and sixteen base-36\n * characters, never one in `taken`.\n *\n * `taken` holds every id the id must not equal — the current groups', the\n * raw declaration's, every site's and every id already minted for the same\n * declaration. Eighty bits make a collision a formality, and the loop makes it\n * impossible.\n */\nexport function mintConsentGroupId(\n taken: ReadonlySet<string>,\n random: () => number = defaultRandom,\n): string {\n for (;;) {\n let id = CONSENT_GROUP_ID_PREFIX\n for (let index = 0; index < 16; index += 1) {\n id += BASE36[Math.min(35, Math.floor(random() * 36))]\n }\n if (!taken.has(id)) return id\n }\n}\n\n/** A group's canonical form: its name trimmed, its sites unique and sorted. */\nfunction canonicalGroup(group: unknown): { name: string; hostIds: string[] } {\n const value = (group && typeof group === 'object' ? group : {}) as Record<string, unknown>\n const name = typeof value['name'] === 'string' ? value['name'].trim() : ''\n const hostIds = Array.isArray(value['hostIds'])\n ? [\n ...new Set(\n (value['hostIds'] as unknown[])\n .map((id) => String(id ?? '').trim())\n .filter(Boolean),\n ),\n ].sort()\n : []\n return { name, hostIds }\n}\n\n/**\n * Whether two declarations say the same thing: the same ids, each with the\n * same trimmed name and the same set of sites. Order never matters.\n */\nexport function consentGroupsEqual(\n a: Record<string, unknown> | null | undefined,\n b: Record<string, unknown> | null | undefined,\n): boolean {\n return consentGroupsFingerprint(a) === consentGroupsFingerprint(b)\n}\n\n/**\n * A declaration as one canonical string: what \"the value the editor\n * rendered\" and \"the value stored now\" are compared by.\n *\n * Every entry is kept, usable or not, because the question is whether the\n * stored value CHANGED, and an edit to an entry `readConsentGroups` drops is\n * still somebody else's edit. Absent, `null` and `{}` are one value — an\n * organization with no groups.\n */\nexport function consentGroupsFingerprint(raw: unknown): string {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return 'null'\n const entries = Object.entries(raw as Record<string, unknown>)\n .map(([id, group]) => [id, canonicalGroup(group)] as const)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return entries.length ? JSON.stringify(entries) : 'null'\n}\n\n/**\n * The next declaration, checked against the organization's sites and the\n * declaration in force, with an id minted for every new group.\n *\n * Refuses everything in the list on {@link ConsentGroupValidationCode}, and\n * reports every problem it finds rather than the first, so the editor can\n * mark each draft at once.\n */\nexport function validateConsentGroupDeclaration(input: {\n groups: unknown\n /** The declaration in force: `readConsentGroups(org)`. */\n before: ConsentGroupDeclaration\n /** The organization's sites: {@link orgSiteIds}. */\n siteIds: readonly string[]\n /** Ids a minted id must also avoid — the raw declaration's, for one. */\n reserved?: Iterable<string>\n random?: () => number\n}): ConsentGroupValidation {\n const { before } = input\n if (!Array.isArray(input.groups)) {\n return { ok: false, errors: [{ code: 'malformed' }] }\n }\n const sites = new Set(input.siteIds)\n const errors: ConsentGroupValidationError[] = []\n const names = new Set<string>()\n const ids = new Set<string>()\n const claimed = new Set<string>()\n const drafts: Array<{ id: string | null; name: string; hostIds: string[] }> = []\n\n input.groups.forEach((value, groupIndex) => {\n if (!value || typeof value !== 'object' || Array.isArray(value)) {\n errors.push({ code: 'malformed', groupIndex })\n return\n }\n const entry = value as Record<string, unknown>\n if (typeof entry['name'] !== 'string' || !Array.isArray(entry['hostIds'])) {\n errors.push({ code: 'malformed', groupIndex })\n return\n }\n const { name, hostIds } = canonicalGroup(entry)\n if (!name) errors.push({ code: 'name-empty', groupIndex })\n else if (name.length > CONSENT_GROUP_NAME_MAX) {\n errors.push({ code: 'name-too-long', groupIndex })\n } else if (names.has(name.toLowerCase())) {\n errors.push({ code: 'name-duplicate', groupIndex })\n }\n if (name) names.add(name.toLowerCase())\n\n for (const hostId of hostIds) {\n if (!sites.has(hostId)) {\n errors.push({ code: 'unknown-site', groupIndex, hostId })\n } else if (claimed.has(hostId)) {\n errors.push({ code: 'site-in-two-groups', groupIndex, hostId })\n }\n claimed.add(hostId)\n }\n if (hostIds.length < 2) errors.push({ code: 'too-few-sites', groupIndex })\n else if (hostIds.length > MAX_CONSENT_GROUP_HOSTS) {\n errors.push({ code: 'too-many-sites', groupIndex })\n }\n\n const rawId = entry['id']\n const id = typeof rawId === 'string' && rawId.trim() ? rawId.trim() : null\n if (id !== null) {\n if (ids.has(id)) errors.push({ code: 'duplicate-group', groupIndex })\n else if (!(id in before)) errors.push({ code: 'unknown-group', groupIndex })\n else if (!before[id].hostIds.some((hostId) => hostIds.includes(hostId))) {\n errors.push({ code: 'group-replaced', groupIndex })\n }\n ids.add(id)\n }\n drafts.push({ id, name, hostIds })\n })\n if (errors.length) return { ok: false, errors }\n\n const taken = new Set<string>([\n ...Object.keys(before),\n ...input.siteIds,\n ...(input.reserved ?? []),\n ...ids,\n ])\n const after: ConsentGroupDeclaration = {}\n for (const draft of drafts) {\n const id = draft.id ?? mintConsentGroupId(taken, input.random)\n taken.add(id)\n after[id] = { name: draft.name, hostIds: draft.hostIds }\n }\n if (consentGroupsEqual(before, after)) {\n return { ok: false, errors: [{ code: 'no-change' }] }\n }\n return { ok: true, after }\n}\n\n/*==========================================\n * THE PLAN\n *=========================================*/\n\n/**\n * One direction of a carry: `toHostId` is about to stop reading\n * `fromHostId`'s refusals, so they are copied onto it first.\n */\nexport interface ConsentGroupCarry {\n toHostId: string\n fromHostId: string\n}\n\n/**\n * Every site a holder key covered moves under ONE new key: the key's records\n * are folded into the new key's, and the old key is deleted in the same\n * write.\n */\nexport interface ConsentGroupHolderMove {\n kind: 'move'\n /** The key the records are under now: a group id, or a solo site's id. */\n from: string\n /** The sites `from` covered. */\n fromHostIds: string[]\n /** The key they move under: always a declared group of the next declaration. */\n to: string\n /** The sites of `to` in the next declaration. */\n toHostIds: string[]\n}\n\n/**\n * The sites a holder key covered go separate ways. Each successor may take a\n * copy of the key's records; the key itself survives when some of its sites\n * stay under it, and is deleted when none do.\n */\nexport interface ConsentGroupHolderSplit {\n kind: 'split'\n from: string\n fromHostIds: string[]\n /** Whether `from` is still a key afterwards. */\n survives: boolean\n /** The sites of `from` that stay under it. Empty when it does not survive. */\n stayHostIds: string[]\n /**\n * Where the other sites go: each successor key with the sites of `from`\n * that map to it — never the successor's other sites, whose records were\n * never under `from`.\n */\n successors: Array<{ key: string; hostIds: string[] }>\n}\n\nexport type ConsentGroupHolderFlow = ConsentGroupHolderMove | ConsentGroupHolderSplit\n\n/** What a change does to the declaration, as the activity log words it. */\nexport type ConsentGroupChangeLine =\n | { kind: 'created'; groupId: string; name: string; hostIds: string[] }\n | { kind: 'renamed'; groupId: string; from: string; to: string }\n | { kind: 'added'; groupId: string; name: string; hostId: string }\n | { kind: 'removed'; groupId: string; name: string; hostId: string }\n | {\n kind: 'moved'\n hostId: string\n fromGroupId: string\n fromName: string\n toGroupId: string\n toName: string\n }\n /** Includes a group absorbed into another: its sites each get a `moved`. */\n | { kind: 'dissolved'; groupId: string; name: string }\n\nexport interface ConsentGroupChangePlan {\n before: ConsentGroupDeclaration\n after: ConsentGroupDeclaration\n carries: ConsentGroupCarry[]\n flows: ConsentGroupHolderFlow[]\n lines: ConsentGroupChangeLine[]\n /**\n * Every site the change touches — whose group, members or group name\n * changes. What the marker names while the change runs: the sites that\n * may not be deleted, and whose holder records may be in motion.\n */\n hostIds: string[]\n /** Only names change: nothing to carry and nothing to re-home. */\n renameOnly: boolean\n}\n\nfunction groupOf(\n declaration: ConsentGroupDeclaration,\n): Map<string, { id: string; hostIds: string[] }> {\n const map = new Map<string, { id: string; hostIds: string[] }>()\n for (const [id, group] of Object.entries(declaration)) {\n for (const hostId of group.hostIds) map.set(hostId, { id, hostIds: group.hostIds })\n }\n return map\n}\n\nconst byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)\n\n/** `declaration` without the sites `keep` does not name. */\nfunction withSitesOnly(\n declaration: ConsentGroupDeclaration,\n keep: ReadonlySet<string>,\n): ConsentGroupDeclaration {\n const out: ConsentGroupDeclaration = {}\n for (const [id, group] of Object.entries(declaration)) {\n const hostIds = group.hostIds.filter((hostId) => keep.has(hostId))\n if (hostIds.length) out[id] = { name: group.name, hostIds }\n }\n return out\n}\n\n/**\n * What moving from `before` to `after` requires.\n *\n * `key(s)` is the holder key of site `s` — its group's id, or `s` itself when\n * it is alone — and `members(s)` the sites of that group, or `[s]`.\n *\n * - **Carries.** For every pair a ≠ b that are one sender now and will not\n * be, `b`'s refusals are copied onto `a`. Pairs that stay together, and\n * pairs that come together, need nothing: a joined site reads its new\n * siblings' refusals the moment the declaration names it.\n * - **Holder flows.** For each key `k` in force, the sites it covers map to\n * a set `T` of keys afterwards. `T = {k}` needs nothing; one other key is\n * a MOVE; anything else is a SPLIT, which `k` survives when it is in `T`.\n *\n * `siteIds`, when given, are the sites the organization still owns: a site\n * named by `before` that is gone neither receives a carry nor takes a copy of\n * a record, because there is nothing left to hold either.\n */\nexport function planConsentGroupChange(\n before: ConsentGroupDeclaration,\n after: ConsentGroupDeclaration,\n options: { siteIds?: readonly string[] } = {},\n): ConsentGroupChangePlan {\n const owned = options.siteIds ? new Set(options.siteIds) : null\n const moving = owned ? withSitesOnly(before, owned) : before\n const beforeOf = groupOf(moving)\n const afterOf = groupOf(after)\n const keyBefore = (hostId: string) => beforeOf.get(hostId)?.id ?? hostId\n const keyAfter = (hostId: string) => afterOf.get(hostId)?.id ?? hostId\n const membersBefore = (hostId: string) => beforeOf.get(hostId)?.hostIds ?? [hostId]\n const membersAfter = (hostId: string) => afterOf.get(hostId)?.hostIds ?? [hostId]\n\n const sites = [\n ...new Set([\n ...Object.values(moving).flatMap((group) => group.hostIds),\n ...Object.values(after).flatMap((group) => group.hostIds),\n ]),\n ].sort(byString)\n\n const carries: ConsentGroupCarry[] = []\n for (const a of sites) {\n const stays = new Set(membersAfter(a))\n for (const b of membersBefore(a)) {\n if (b !== a && !stays.has(b)) carries.push({ toHostId: a, fromHostId: b })\n }\n }\n\n const flows: ConsentGroupHolderFlow[] = []\n const keys = [...new Set(sites.map(keyBefore))].sort(byString)\n for (const key of keys) {\n const covered = sites.filter((hostId) => keyBefore(hostId) === key)\n const targets = [...new Set(covered.map(keyAfter))].sort(byString)\n if (targets.length === 1 && targets[0] === key) continue\n if (targets.length === 1) {\n flows.push({\n kind: 'move',\n from: key,\n fromHostIds: covered,\n to: targets[0],\n toHostIds: [...membersAfter(covered[0])],\n })\n continue\n }\n flows.push({\n kind: 'split',\n from: key,\n fromHostIds: covered,\n survives: targets.includes(key),\n stayHostIds: covered.filter((hostId) => keyAfter(hostId) === key),\n successors: targets\n .filter((target) => target !== key)\n .map((target) => ({\n key: target,\n hostIds: covered.filter((hostId) => keyAfter(hostId) === target),\n })),\n })\n }\n\n const lines = planLines(before, after)\n const touched = new Set<string>()\n const beforeAll = groupOf(before)\n for (const hostId of new Set([...beforeAll.keys(), ...afterOf.keys()])) {\n const was = beforeAll.get(hostId)\n const now = afterOf.get(hostId)\n const sameMembers =\n (was?.hostIds ?? [hostId]).join('\\n') === (now?.hostIds ?? [hostId]).join('\\n')\n const sameName =\n !was || !now || before[was.id]?.name === after[now.id]?.name\n if (was?.id !== now?.id || !sameMembers || !sameName) touched.add(hostId)\n }\n return {\n before,\n after,\n carries,\n flows,\n lines,\n hostIds: [...touched].sort(byString),\n renameOnly:\n !carries.length &&\n !flows.length &&\n lines.length > 0 &&\n lines.every((line) => line.kind === 'renamed'),\n }\n}\n\n/** The declaration-level changes, in the order the activity log lists them. */\nfunction planLines(\n before: ConsentGroupDeclaration,\n after: ConsentGroupDeclaration,\n): ConsentGroupChangeLine[] {\n const beforeOf = groupOf(before)\n const afterOf = groupOf(after)\n const created: ConsentGroupChangeLine[] = []\n const renamed: ConsentGroupChangeLine[] = []\n const added: ConsentGroupChangeLine[] = []\n const removed: ConsentGroupChangeLine[] = []\n const moved: ConsentGroupChangeLine[] = []\n const dissolved: ConsentGroupChangeLine[] = []\n const moveInto = (hostId: string, toGroupId: string) => {\n const from = beforeOf.get(hostId)\n if (!from || from.id === toGroupId) return false\n moved.push({\n kind: 'moved',\n hostId,\n fromGroupId: from.id,\n fromName: before[from.id].name,\n toGroupId,\n toName: after[toGroupId].name,\n })\n return true\n }\n\n for (const id of Object.keys(after).sort(byString)) {\n const next = after[id]\n const previous = before[id]\n if (!previous) {\n created.push({ kind: 'created', groupId: id, name: next.name, hostIds: [...next.hostIds] })\n for (const hostId of next.hostIds) moveInto(hostId, id)\n continue\n }\n if (previous.name !== next.name) {\n renamed.push({ kind: 'renamed', groupId: id, from: previous.name, to: next.name })\n }\n for (const hostId of next.hostIds) {\n if (previous.hostIds.includes(hostId)) continue\n if (!moveInto(hostId, id)) {\n added.push({ kind: 'added', groupId: id, name: next.name, hostId })\n }\n }\n for (const hostId of previous.hostIds) {\n if (next.hostIds.includes(hostId)) continue\n // A site that went to another group is that group's `moved` line.\n if (!afterOf.has(hostId)) {\n removed.push({ kind: 'removed', groupId: id, name: next.name, hostId })\n }\n }\n }\n for (const id of Object.keys(before).sort(byString)) {\n if (!(id in after)) dissolved.push({ kind: 'dissolved', groupId: id, name: before[id].name })\n }\n return [...created, ...renamed, ...added, ...removed, ...moved, ...dissolved]\n}\n\n/**\n * The sites in a sentence: \"A\", \"A and B\", \"A, B and C\".\n */\nexport function listSiteNames(names: readonly string[]): string {\n if (names.length <= 1) return names[0] ?? ''\n return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`\n}\n\n/**\n * One line of the plan as the organization's activity log says it.\n *\n * `siteName` turns a site id into what the console shows for it; the id\n * itself is the fallback, so a line is never blank.\n */\nexport function describeConsentGroupChangeLine(\n line: ConsentGroupChangeLine,\n siteName: (hostId: string) => string = (hostId) => hostId,\n): string {\n const site = (hostId: string) => siteName(hostId) || hostId\n switch (line.kind) {\n case 'created':\n return `Created consent group \"${line.name}\" with ${listSiteNames(line.hostIds.map(site))}`\n case 'renamed':\n return `Renamed consent group \"${line.from}\" to \"${line.to}\"`\n case 'added':\n return `Added ${site(line.hostId)} to consent group \"${line.name}\"`\n case 'removed':\n return `Removed ${site(line.hostId)} from consent group \"${line.name}\"`\n case 'moved':\n return `Moved ${site(line.hostId)} from consent group \"${line.fromName}\" to \"${line.toName}\"`\n case 'dissolved':\n return `Dissolved consent group \"${line.name}\"`\n }\n}\n\n/*==========================================\n * THE CHANGE IN FLIGHT\n *=========================================*/\n\n/**\n * Where a change stands, on the org marker.\n *\n * - `carry` — copying refusals; the declaration in force is the old one,\n * and the change can still be canceled.\n * - `rehome` — the new declaration is in force; holder records are moving.\n * - `sweep` — waiting out {@link CONSENT_GROUP_SWEEP_DELAY_MS}, then one\n * last idempotent pass.\n */\nexport type ConsentGroupsChangePhase = 'carry' | 'rehome' | 'sweep'\n\n/** `org.consentGroupsChange` — present exactly while a change runs. */\nexport interface ConsentGroupsChangeMarker {\n changeId: string\n phase: ConsentGroupsChangePhase\n /** {@link ConsentGroupChangePlan.hostIds}. */\n hostIds: string[]\n startedAtMs: number\n /** When the new declaration took effect; absent before the flip. */\n declaredAtMs?: number\n}\n\nconst PHASES: readonly ConsentGroupsChangePhase[] = ['carry', 'rehome', 'sweep']\n\n/** The marker off an org document, or `null` when no change is in flight. */\nexport function readConsentGroupsChange(\n org: Record<string, unknown> | null | undefined,\n): ConsentGroupsChangeMarker | null {\n const raw = (org ?? {})[CONSENT_GROUPS_CHANGE_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null\n const value = raw as Record<string, unknown>\n const changeId = typeof value['changeId'] === 'string' ? value['changeId'] : ''\n const phase = PHASES.find((candidate) => candidate === value['phase'])\n if (!changeId || !phase) return null\n const declaredAtMs = Number(value['declaredAtMs'])\n return {\n changeId,\n phase,\n hostIds: Array.isArray(value['hostIds'])\n ? (value['hostIds'] as unknown[]).filter(\n (id): id is string => typeof id === 'string' && id !== '',\n )\n : [],\n startedAtMs: Number(value['startedAtMs']) || 0,\n ...(Number.isFinite(declaredAtMs) && declaredAtMs > 0 ? { declaredAtMs } : {}),\n }\n}\n\n/**\n * Whether the holder records `hostId` reads may be mid-move: a change in\n * flight has flipped the declaration and names this site.\n *\n * A reader that decides something by a record's ABSENCE — a dynamic list\n * that removes whoever no longer matches — must not decide it while this\n * holds, because a record that has not moved yet reads as missing.\n */\nexport function consentGroupHoldersInMotion(\n org: Record<string, unknown> | null | undefined,\n hostId: string,\n): boolean {\n const marker = readConsentGroupsChange(org)\n return Boolean(\n marker && marker.phase !== 'carry' && marker.hostIds.includes(hostId),\n )\n}\n\n/**\n * The executor's steps, in order.\n *\n * - `carry` — every carry, then every participant's carry phase.\n * - `catch-up-pre` — the refusals filed since the carry began.\n * - `declare` — the flip, in one transaction.\n * - `catch-up-post` — the same catch-up, for what raced the flip.\n * - `rehome` — every participant's holder records.\n * - `sweep-wait` — until {@link CONSENT_GROUP_SWEEP_DELAY_MS} has passed.\n * - `sweep` — every carry again in full, and every participant's sweep.\n * - `finish` — the marker cleared.\n *\n * A rename-only change runs `declare` and `finish` alone.\n */\nexport const CONSENT_GROUP_CHANGE_STEPS = [\n 'carry',\n 'catch-up-pre',\n 'declare',\n 'catch-up-post',\n 'rehome',\n 'sweep-wait',\n 'sweep',\n 'finish',\n] as const\n\nexport type ConsentGroupChangeStep = (typeof CONSENT_GROUP_CHANGE_STEPS)[number]\n\n/** The steps a rename-only change runs. */\nexport const CONSENT_GROUP_RENAME_STEPS: readonly ConsentGroupChangeStep[] = [\n 'declare',\n 'finish',\n]\n\n/** The marker phase a step runs under. */\nexport function consentGroupChangePhaseOf(\n step: ConsentGroupChangeStep,\n): ConsentGroupsChangePhase {\n if (step === 'carry' || step === 'catch-up-pre' || step === 'declare') return 'carry'\n if (step === 'catch-up-post' || step === 'rehome') return 'rehome'\n return 'sweep'\n}\n\nexport type ConsentGroupChangeStatus = 'running' | 'done' | 'canceled'\n\n/** What the refusal carries copied so far, across every step. */\nexport interface ConsentGroupChangeCounts {\n siteSuppressions: number\n topicOptOuts: number\n paces: number\n}\n\n/** A job's state, as the route answers it and the progress panel renders it. */\nexport interface ConsentGroupChangeProgress {\n status: ConsentGroupChangeStatus\n step: ConsentGroupChangeStep\n /** 1-based position of `step` among the steps this change runs. */\n stepIndex: number\n stepCount: number\n /** When the new declaration took effect, or `null` before the flip. */\n declaredAtMs: number | null\n /** When the sweep may start, or `null` before the flip. */\n sweepAtMs: number | null\n /** Five units in a row failed. The change keeps retrying. */\n stalled: boolean\n /** Consecutive failed units. */\n failures: number\n lastError: string | null\n /** Until when another caller holds the job; `null` when it is free. */\n leaseUntilMs: number | null\n counts: ConsentGroupChangeCounts\n /** The sites that received at least one carried refusal. */\n sitesReceiving: number\n /** Each participant's counts, by plugin id. */\n plugins: Record<string, Record<string, number>>\n}\n\n/*==========================================\n * THE ROUTE'S CONTRACT (`POST /api/orgs/consent-groups`)\n *=========================================*/\n\nexport type ConsentGroupsApiAction = 'preview' | 'apply' | 'continue' | 'cancel' | 'status'\n\nexport interface ConsentGroupsApiDeclarationRequest {\n orgId: string\n action: 'preview' | 'apply'\n /**\n * `org.consentGroups` exactly as the editor rendered it, or `null` for an\n * organization with none. Compared by {@link consentGroupsFingerprint}.\n */\n expected: Record<string, unknown> | null\n /** The COMPLETE next declaration. A new group has no id. */\n groups: ConsentGroupDraft[]\n}\n\nexport interface ConsentGroupsApiChangeRequest {\n orgId: string\n action: 'continue' | 'cancel' | 'status'\n changeId: string\n}\n\nexport type ConsentGroupsApiRequest =\n | ConsentGroupsApiDeclarationRequest\n | ConsentGroupsApiChangeRequest\n\n/** 200 for `preview`. */\nexport interface ConsentGroupsApiPreviewResponse {\n ok: true\n preview: ConsentGroupChangePreview\n}\n\n/** 200 for `apply`, `continue`, `cancel` and `status`. */\nexport interface ConsentGroupsApiChangeResponse {\n ok: true\n changeId: string\n phase: ConsentGroupsChangePhase | 'done' | 'canceled'\n done: boolean\n progress: ConsentGroupChangeProgress\n}\n\n/** 400: the declaration was refused. */\nexport interface ConsentGroupsApiRefusal {\n error: string\n errors: ConsentGroupValidationError[]\n}\n\n/** 409: the declaration changed since the editor rendered it. */\nexport interface ConsentGroupsApiStale {\n error: string\n current: Record<string, unknown> | null\n}\n\n/** 409: another change is running, or a cancel came after the flip. */\nexport interface ConsentGroupsApiInFlight {\n error: string\n changeId: string\n phase: ConsentGroupsChangePhase | 'done' | 'canceled'\n}\n\n/*==========================================\n * THE PREVIEW (`action: 'preview'`)\n *=========================================*/\n\n/**\n * One sentence a participant adds to the review step, with the figure it is\n * about. `id` is namespaced by the participant (`crm.combine`).\n */\nexport interface ConsentGroupChangePreviewLine {\n id: string\n text: string\n count: number | null\n severity: 'info' | 'warning'\n}\n\n/** How long a change should take, from the work the preview counted. */\nexport type ConsentGroupChangeEstimate = 'instant' | 'under-a-minute' | 'minutes' | 'long'\n\n/** The work bands behind {@link ConsentGroupChangeEstimate}. */\nexport function estimateConsentGroupChange(\n plan: Pick<ConsentGroupChangePlan, 'renameOnly'>,\n documents: number,\n): ConsentGroupChangeEstimate {\n if (plan.renameOnly) return 'instant'\n if (documents < 5_000) return 'under-a-minute'\n if (documents < 100_000) return 'minutes'\n return 'long'\n}\n\nexport interface ConsentGroupChangePreview {\n before: ConsentGroupDeclaration\n after: ConsentGroupDeclaration\n /** Stored ids `readConsentGroups` drops; the write removes them. */\n discarded: string[]\n /** The plan's lines, each with the sentence the activity log will write. */\n lines: Array<ConsentGroupChangeLine & { text: string }>\n /** Every group whose signup-form sentence changes. */\n disclosures: Array<{\n groupId: string\n hostIds: string[]\n before: string | null\n after: string | null\n }>\n /** Per carry, what the source site holds now (count aggregations). */\n carries: Array<{\n toHostId: string\n fromHostId: string\n siteSuppressions: number\n topicOptOuts: number\n paces: number\n }>\n /** Per joining site, the refusals on its new siblings it starts honoring. */\n inherited: Array<{ hostId: string; refusals: number }>\n /**\n * Pending confirmations that stop holding a separated site's mail. Only\n * when the organization's confirmation switch is on.\n */\n pendingHolds: Array<{\n hostId: string\n topicId: string\n count: number\n releasedHostIds: string[]\n }>\n /** Team members who can open only some of a changed group's sites. */\n partialAccessMembers: number\n /**\n * Set when the organization's consent policy is `forward` and a site joins:\n * the joining sites may email people their new siblings captured before\n * `enforceFromMs` without a recorded opt-in.\n */\n forwardPolicyWarning: { hostIds: string[]; enforceFromMs: number } | null\n /** Each participant's lines; `null` for one whose preview failed. */\n participants: Array<{\n pluginId: string\n lines: ConsentGroupChangePreviewLine[] | null\n }>\n /** The capture surfaces that show a group's disclosure. */\n capturesDisclosing: string[]\n estimate: ConsentGroupChangeEstimate\n}\n\n/** The groups whose disclosure sentence differs between the declarations. */\nexport function consentGroupDisclosureChanges(\n before: ConsentGroupDeclaration,\n after: ConsentGroupDeclaration,\n): ConsentGroupChangePreview['disclosures'] {\n const sentence = (id: string, group: StoredConsentGroup | undefined) =>\n group\n ? consentGroupDisclosure({\n hostId: group.hostIds[0],\n groupId: id,\n name: group.name,\n hostIds: group.hostIds,\n declared: true,\n awaitsConfirmation: false,\n })\n : null\n const out: ConsentGroupChangePreview['disclosures'] = []\n for (const id of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(byString)) {\n const was = sentence(id, before[id])\n const now = sentence(id, after[id])\n if (was === now) continue\n out.push({\n groupId: id,\n hostIds: [...(after[id] ?? before[id]).hostIds],\n before: was,\n after: now,\n })\n }\n return out\n}\n"],"names":["consentGroupDisclosure","CONSENT_GROUPS_FIELD","MAX_CONSENT_GROUP_HOSTS","readConsentGroups","CONSENT_GROUPS_CHANGE_FIELD","CONSENT_GROUP_CHANGES_COLLECTION","CONSENT_GROUP_NAME_MAX","CONSENT_GROUP_SWEEP_DELAY_MS","CONSENT_GROUP_ID_PREFIX","orgSiteIds","org","hosts","ids","Array","isArray","map","id","String","Object","entries","filter","value","Set","trim","Boolean","sort","discardedConsentGroupIds","raw","usable","keys","BASE36","defaultRandom","crypto","globalThis","getRandomValues","word","Uint32Array","Math","random","mintConsentGroupId","taken","index","min","floor","has","canonicalGroup","group","name","hostIds","consentGroupsEqual","a","b","consentGroupsFingerprint","length","JSON","stringify","validateConsentGroupDeclaration","input","before","groups","ok","errors","code","sites","siteIds","names","claimed","drafts","forEach","groupIndex","push","entry","toLowerCase","add","hostId","rawId","some","includes","reserved","after","draft","groupOf","declaration","Map","set","byString","withSitesOnly","keep","out","planConsentGroupChange","options","owned","moving","beforeOf","afterOf","keyBefore","get","keyAfter","membersBefore","membersAfter","values","flatMap","carries","stays","toHostId","fromHostId","flows","key","covered","targets","kind","from","fromHostIds","to","toHostIds","survives","stayHostIds","successors","target","lines","planLines","touched","beforeAll","was","now","sameMembers","join","sameName","renameOnly","every","line","created","renamed","added","removed","moved","dissolved","moveInto","toGroupId","fromGroupId","fromName","toName","next","previous","groupId","listSiteNames","slice","describeConsentGroupChangeLine","siteName","site","PHASES","readConsentGroupsChange","changeId","phase","find","candidate","declaredAtMs","Number","startedAtMs","isFinite","consentGroupHoldersInMotion","marker","CONSENT_GROUP_CHANGE_STEPS","CONSENT_GROUP_RENAME_STEPS","consentGroupChangePhaseOf","step","estimateConsentGroupChange","plan","documents","consentGroupDisclosureChanges","sentence","declared","awaitsConfirmation"],"mappings":"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"}
|
|
@@ -51,6 +51,18 @@
|
|
|
51
51
|
* in a nameless group rather than pooling silently. That refusal is the whole
|
|
52
52
|
* mechanism by which "declared" is stronger than "configured".
|
|
53
53
|
*
|
|
54
|
+
* ## …and a grant pools only where the name was actually SHOWN
|
|
55
|
+
*
|
|
56
|
+
* Disclosable is not disclosed. A form rendered before the group existed, a
|
|
57
|
+
* checkout that has never displayed the sentence, and a page cached across a
|
|
58
|
+
* rename all capture under a declaration their visitor never read. So a
|
|
59
|
+
* capture surface that renders {@link consentGroupDisclosure} sends back the
|
|
60
|
+
* {@link consentGroupDisclosureKey} of what it rendered, and
|
|
61
|
+
* {@link consentGroupForGrant} pools the grant only when that key matches the
|
|
62
|
+
* group as it stands. Anything else — no key, a stale key, a surface that
|
|
63
|
+
* never renders the sentence — records the capturing site alone. Visibility
|
|
64
|
+
* is not narrowed with it: that axis follows the group, see below.
|
|
65
|
+
*
|
|
54
66
|
* ## Pooling applies FORWARD only
|
|
55
67
|
*
|
|
56
68
|
* A grant records the group AS DISCLOSED at the moment it was given, in the
|
|
@@ -58,7 +70,9 @@
|
|
|
58
70
|
* group therefore reaches captures made after the change and none made
|
|
59
71
|
* before. An org-level switch that widened existing grants would be the leak
|
|
60
72
|
* wearing a different hat: the people already on the list were told a
|
|
61
|
-
* different thing.
|
|
73
|
+
* different thing. The same holds for a grant carried from one record to
|
|
74
|
+
* another — a list enrollment, a lead becoming a contact: it is copied as it
|
|
75
|
+
* was recorded, never re-derived from the group as it stands now.
|
|
62
76
|
*
|
|
63
77
|
* OPT-OUT runs the other way — read against the CURRENT group, so a site
|
|
64
78
|
* joining a group inherits every refusal already standing against it. The
|
|
@@ -86,12 +100,25 @@
|
|
|
86
100
|
* Off is the default and the absence, and an org that never set it reads
|
|
87
101
|
* exactly the documents it read before the field existed.
|
|
88
102
|
*
|
|
89
|
-
* ⚠️ The one change that runs AGAINST reading is
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
103
|
+
* ⚠️ The one change that runs AGAINST reading is sites SEPARATING — a site
|
|
104
|
+
* leaving a group, moving to another, or a group dissolving. A refusal is
|
|
105
|
+
* stored on the site the person acted on, so once two sites stop being one
|
|
106
|
+
* sender, each stops seeing the refusals filed against the other, and every
|
|
107
|
+
* one of them was a refusal of the sender both sites were at the time. So a
|
|
108
|
+
* change to this declaration carries them BOTH WAYS before it takes effect:
|
|
109
|
+
* the sites that stay keep every opt-out filed on the site that leaves, and
|
|
110
|
+
* the site that leaves keeps every opt-out filed on the sites it leaves —
|
|
111
|
+
* the preference page promised the person that an opt-out from the group
|
|
112
|
+
* covers all of it, and separating the sites must not quietly break that.
|
|
113
|
+
* The consent group change (the pure plan in `consent-group-change.ts`, run
|
|
114
|
+
* by the executor in `@aglyn/tenant-data-admin`, started from the Emails
|
|
115
|
+
* hub's Consent groups section through `POST /api/orgs/consent-groups`) is
|
|
116
|
+
* the one writer of this field, and it carries the site suppression rows,
|
|
117
|
+
* the topic opt-outs, the pace and a contact's per-site refusal before it
|
|
118
|
+
* flips the declaration.
|
|
119
|
+
* A site that is in a group, or in a change still running, cannot be
|
|
120
|
+
* deleted until it is out: deletion would destroy refusals a sibling still
|
|
121
|
+
* reads.
|
|
95
122
|
*
|
|
96
123
|
* ## VISIBILITY IS A SEPARATE AXIS
|
|
97
124
|
*
|
|
@@ -200,12 +227,19 @@ export declare function consentGroupsAwaitConfirmation(org: Record<string, unkno
|
|
|
200
227
|
* value of this field is that pooling is deliberate, and a corrupt value must
|
|
201
228
|
* not be a way to reach an audience nobody declared.
|
|
202
229
|
*
|
|
203
|
-
* The
|
|
230
|
+
* The five refusals, each of which would otherwise pool without a disclosure
|
|
231
|
+
* or file one sender's records under another's key:
|
|
204
232
|
*
|
|
205
233
|
* - **no usable name** — cannot be shown on a form, so cannot be disclosed;
|
|
206
234
|
* - **fewer than two sites** — not a pooling declaration, and a group of one
|
|
207
235
|
* is what an undeclared site already gets;
|
|
208
236
|
* - **over {@link MAX_CONSENT_GROUP_HOSTS}** — see that constant;
|
|
237
|
+
* - **an id that is a site's id** — a site that declared nothing uses its
|
|
238
|
+
* own id as its group id, so a group spelled the same way would share
|
|
239
|
+
* that site's key: its grant stamps, its opt-out lookups and the records
|
|
240
|
+
* the CRM files under the group id would read as the lone site's, and the
|
|
241
|
+
* lone site's as the group's. Checked against every site the org holds
|
|
242
|
+
* and every site any entry names, usable or not;
|
|
209
243
|
* - **a site claimed by two groups** — two controllers claiming one site is
|
|
210
244
|
* a contradiction, not a wider group, so BOTH claims are dropped and the
|
|
211
245
|
* site falls back to being alone.
|
|
@@ -220,6 +254,34 @@ export declare function readConsentGroups(org: Record<string, unknown> | null |
|
|
|
220
254
|
* exists to make impossible.
|
|
221
255
|
*/
|
|
222
256
|
export declare function consentGroupForHost(org: Record<string, unknown> | null | undefined, hostId: string): ConsentGroup;
|
|
257
|
+
/** Why a site may not be deleted yet — see {@link consentGroupSiteHold}. */
|
|
258
|
+
export type ConsentGroupSiteHold =
|
|
259
|
+
/** The site is one sender with other sites, under this group. */
|
|
260
|
+
{
|
|
261
|
+
reason: 'grouped';
|
|
262
|
+
groupId: string;
|
|
263
|
+
name: string;
|
|
264
|
+
}
|
|
265
|
+
/** A consent group change naming the site has not finished. */
|
|
266
|
+
| {
|
|
267
|
+
reason: 'changing';
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Whether deleting `hostId` would take refusals its consent group still
|
|
271
|
+
* needs, and why — or `null` when the site may go (AGL-3320).
|
|
272
|
+
*
|
|
273
|
+
* A site's opt-outs are stored on the site and read across its group, so
|
|
274
|
+
* erasing a grouped site erases refusals its siblings honor today, and
|
|
275
|
+
* leaves the declaration naming a site that no longer exists. A site named
|
|
276
|
+
* by a change still in progress is the same hazard in motion: the change
|
|
277
|
+
* may be copying its opt-outs out, or relying on them being there. So the
|
|
278
|
+
* site leaves its group first — which carries its refusals to the sites
|
|
279
|
+
* that stay — and is deleted after.
|
|
280
|
+
*
|
|
281
|
+
* Deleting the whole organization is the one exception, and is the caller's
|
|
282
|
+
* to make: nothing is left to read the refusals.
|
|
283
|
+
*/
|
|
284
|
+
export declare function consentGroupSiteHold(org: Record<string, unknown> | null | undefined, hostId: string): ConsentGroupSiteHold | null;
|
|
223
285
|
/**
|
|
224
286
|
* The sites whose OPT-OUTS answer for mail sent by `group.hostId`, the
|
|
225
287
|
* sending site first.
|
|
@@ -264,3 +326,39 @@ export declare function consentGroupScope(group: ConsentGroup): ScopeToken[];
|
|
|
264
326
|
* and the recorded set is the one that decides who mails this person.
|
|
265
327
|
*/
|
|
266
328
|
export declare function consentGroupDisclosure(group: ConsentGroup): string | null;
|
|
329
|
+
/**
|
|
330
|
+
* A short fingerprint of the disclosure a capture surface rendered, or
|
|
331
|
+
* `null` when {@link consentGroupDisclosure} has nothing to render.
|
|
332
|
+
*
|
|
333
|
+
* The surface sends it back with the submission, and
|
|
334
|
+
* {@link consentGroupForGrant} pools only when it still matches: a form
|
|
335
|
+
* rendered before a rename, before a site joined, or before the group
|
|
336
|
+
* existed at all carries a key that no longer does, and its grant narrows to
|
|
337
|
+
* the site the person was on rather than reaching sites they were never told
|
|
338
|
+
* about.
|
|
339
|
+
*
|
|
340
|
+
* Over the three things the sentence and the grant stand for — the group's
|
|
341
|
+
* id, its name and its sites, sorted — serialized as JSON so no name can
|
|
342
|
+
* run into an id. FNV-1a over the UTF-16 code units, which keeps it
|
|
343
|
+
* synchronous and identical in the browser and on the server with nothing
|
|
344
|
+
* to import. It is not a secret and proves no identity: it answers "was it
|
|
345
|
+
* THIS disclosure", and a caller willing to lie could as easily lie about
|
|
346
|
+
* the checkbox.
|
|
347
|
+
*/
|
|
348
|
+
export declare function consentGroupDisclosureKey(group: ConsentGroup): string | null;
|
|
349
|
+
/**
|
|
350
|
+
* The group a GRANT is recorded for: `group` itself only when it is declared
|
|
351
|
+
* and `disclosureKey` is exactly its current {@link consentGroupDisclosureKey};
|
|
352
|
+
* otherwise the capturing site alone.
|
|
353
|
+
*
|
|
354
|
+
* Called by every door that records a grant, with whatever key the capture
|
|
355
|
+
* surface sent back — none, for every surface that does not render the
|
|
356
|
+
* disclosure. Narrow is the failure direction on purpose: a grant that should
|
|
357
|
+
* have pooled and did not withholds mail from a sibling site, where one that
|
|
358
|
+
* pooled on a sentence nobody saw sends it.
|
|
359
|
+
*
|
|
360
|
+
* Only the grant narrows. The same capture still stamps `visibleTo` from the
|
|
361
|
+
* whole group, and still reads refusals across it: who may SEE a person and
|
|
362
|
+
* who may MAIL them are separate questions — see the module note.
|
|
363
|
+
*/
|
|
364
|
+
export declare function consentGroupForGrant(group: ConsentGroup, disclosureKey: string | null | undefined): ConsentGroup;
|
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
* in a nameless group rather than pooling silently. That refusal is the whole
|
|
51
51
|
* mechanism by which "declared" is stronger than "configured".
|
|
52
52
|
*
|
|
53
|
+
* ## …and a grant pools only where the name was actually SHOWN
|
|
54
|
+
*
|
|
55
|
+
* Disclosable is not disclosed. A form rendered before the group existed, a
|
|
56
|
+
* checkout that has never displayed the sentence, and a page cached across a
|
|
57
|
+
* rename all capture under a declaration their visitor never read. So a
|
|
58
|
+
* capture surface that renders {@link consentGroupDisclosure} sends back the
|
|
59
|
+
* {@link consentGroupDisclosureKey} of what it rendered, and
|
|
60
|
+
* {@link consentGroupForGrant} pools the grant only when that key matches the
|
|
61
|
+
* group as it stands. Anything else — no key, a stale key, a surface that
|
|
62
|
+
* never renders the sentence — records the capturing site alone. Visibility
|
|
63
|
+
* is not narrowed with it: that axis follows the group, see below.
|
|
64
|
+
*
|
|
53
65
|
* ## Pooling applies FORWARD only
|
|
54
66
|
*
|
|
55
67
|
* A grant records the group AS DISCLOSED at the moment it was given, in the
|
|
@@ -57,7 +69,9 @@
|
|
|
57
69
|
* group therefore reaches captures made after the change and none made
|
|
58
70
|
* before. An org-level switch that widened existing grants would be the leak
|
|
59
71
|
* wearing a different hat: the people already on the list were told a
|
|
60
|
-
* different thing.
|
|
72
|
+
* different thing. The same holds for a grant carried from one record to
|
|
73
|
+
* another — a list enrollment, a lead becoming a contact: it is copied as it
|
|
74
|
+
* was recorded, never re-derived from the group as it stands now.
|
|
61
75
|
*
|
|
62
76
|
* OPT-OUT runs the other way — read against the CURRENT group, so a site
|
|
63
77
|
* joining a group inherits every refusal already standing against it. The
|
|
@@ -85,12 +99,25 @@
|
|
|
85
99
|
* Off is the default and the absence, and an org that never set it reads
|
|
86
100
|
* exactly the documents it read before the field existed.
|
|
87
101
|
*
|
|
88
|
-
* ⚠️ The one change that runs AGAINST reading is
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
102
|
+
* ⚠️ The one change that runs AGAINST reading is sites SEPARATING — a site
|
|
103
|
+
* leaving a group, moving to another, or a group dissolving. A refusal is
|
|
104
|
+
* stored on the site the person acted on, so once two sites stop being one
|
|
105
|
+
* sender, each stops seeing the refusals filed against the other, and every
|
|
106
|
+
* one of them was a refusal of the sender both sites were at the time. So a
|
|
107
|
+
* change to this declaration carries them BOTH WAYS before it takes effect:
|
|
108
|
+
* the sites that stay keep every opt-out filed on the site that leaves, and
|
|
109
|
+
* the site that leaves keeps every opt-out filed on the sites it leaves —
|
|
110
|
+
* the preference page promised the person that an opt-out from the group
|
|
111
|
+
* covers all of it, and separating the sites must not quietly break that.
|
|
112
|
+
* The consent group change (the pure plan in `consent-group-change.ts`, run
|
|
113
|
+
* by the executor in `@aglyn/tenant-data-admin`, started from the Emails
|
|
114
|
+
* hub's Consent groups section through `POST /api/orgs/consent-groups`) is
|
|
115
|
+
* the one writer of this field, and it carries the site suppression rows,
|
|
116
|
+
* the topic opt-outs, the pace and a contact's per-site refusal before it
|
|
117
|
+
* flips the declaration.
|
|
118
|
+
* A site that is in a group, or in a change still running, cannot be
|
|
119
|
+
* deleted until it is out: deletion would destroy refusals a sibling still
|
|
120
|
+
* reads.
|
|
94
121
|
*
|
|
95
122
|
* ## VISIBILITY IS A SEPARATE AXIS
|
|
96
123
|
*
|
|
@@ -164,21 +191,30 @@
|
|
|
164
191
|
* value of this field is that pooling is deliberate, and a corrupt value must
|
|
165
192
|
* not be a way to reach an audience nobody declared.
|
|
166
193
|
*
|
|
167
|
-
* The
|
|
194
|
+
* The five refusals, each of which would otherwise pool without a disclosure
|
|
195
|
+
* or file one sender's records under another's key:
|
|
168
196
|
*
|
|
169
197
|
* - **no usable name** — cannot be shown on a form, so cannot be disclosed;
|
|
170
198
|
* - **fewer than two sites** — not a pooling declaration, and a group of one
|
|
171
199
|
* is what an undeclared site already gets;
|
|
172
200
|
* - **over {@link MAX_CONSENT_GROUP_HOSTS}** — see that constant;
|
|
201
|
+
* - **an id that is a site's id** — a site that declared nothing uses its
|
|
202
|
+
* own id as its group id, so a group spelled the same way would share
|
|
203
|
+
* that site's key: its grant stamps, its opt-out lookups and the records
|
|
204
|
+
* the CRM files under the group id would read as the lone site's, and the
|
|
205
|
+
* lone site's as the group's. Checked against every site the org holds
|
|
206
|
+
* and every site any entry names, usable or not;
|
|
173
207
|
* - **a site claimed by two groups** — two controllers claiming one site is
|
|
174
208
|
* a contradiction, not a wider group, so BOTH claims are dropped and the
|
|
175
209
|
* site falls back to being alone.
|
|
176
210
|
*/ export function readConsentGroups(org) {
|
|
177
211
|
const raw = (org != null ? org : {})[CONSENT_GROUPS_FIELD];
|
|
178
212
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
213
|
+
const siteIds = consentGroupSiteIds(org, raw);
|
|
179
214
|
const usable = {};
|
|
180
215
|
for (const [groupId, value] of Object.entries(raw)){
|
|
181
216
|
if (!groupId || !value || typeof value !== 'object') continue;
|
|
217
|
+
if (siteIds.has(groupId)) continue;
|
|
182
218
|
const group = value;
|
|
183
219
|
const name = typeof group['name'] === 'string' ? group['name'].trim() : '';
|
|
184
220
|
if (!name) continue;
|
|
@@ -213,6 +249,33 @@
|
|
|
213
249
|
}
|
|
214
250
|
return settled;
|
|
215
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Every id that names a site as far as the declaration can tell: the org's
|
|
254
|
+
* own `hosts` and every site any raw entry names, including the entries the
|
|
255
|
+
* other refusals will drop.
|
|
256
|
+
*
|
|
257
|
+
* Wider than the usable set on purpose. A group id that collides with a
|
|
258
|
+
* site the org has not linked yet, or with a site named only by a broken
|
|
259
|
+
* entry, is the same collision one edit later, and refusing it now costs a
|
|
260
|
+
* declaration nothing a valid one would have.
|
|
261
|
+
*/ function consentGroupSiteIds(org, raw) {
|
|
262
|
+
const ids = new Set();
|
|
263
|
+
const hosts = (org != null ? org : {})['hosts'];
|
|
264
|
+
if (Array.isArray(hosts)) {
|
|
265
|
+
for (const id of hosts)if (typeof id === 'string' && id) ids.add(id);
|
|
266
|
+
} else if (hosts && typeof hosts === 'object') {
|
|
267
|
+
for (const id of Object.keys(hosts))if (id) ids.add(id);
|
|
268
|
+
}
|
|
269
|
+
for (const value of Object.values(raw)){
|
|
270
|
+
const hostIds = value == null ? void 0 : value.hostIds;
|
|
271
|
+
if (!Array.isArray(hostIds)) continue;
|
|
272
|
+
for (const id of hostIds){
|
|
273
|
+
const trimmed = String(id != null ? id : '').trim();
|
|
274
|
+
if (trimmed) ids.add(trimmed);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return ids;
|
|
278
|
+
}
|
|
216
279
|
/**
|
|
217
280
|
* The group `hostId` belongs to — the declared one, or the group of one.
|
|
218
281
|
*
|
|
@@ -239,6 +302,45 @@
|
|
|
239
302
|
}
|
|
240
303
|
return soloConsentGroup(hostId);
|
|
241
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* The org field a running consent group change marks itself with:
|
|
307
|
+
* `{ changeId, phase, hostIds, … }`, present from the moment the change
|
|
308
|
+
* starts until it has finished. Only its `hostIds` is read here.
|
|
309
|
+
*/ const CONSENT_GROUP_CHANGE_MARKER_FIELD = 'consentGroupsChange';
|
|
310
|
+
/**
|
|
311
|
+
* Whether deleting `hostId` would take refusals its consent group still
|
|
312
|
+
* needs, and why — or `null` when the site may go (AGL-3320).
|
|
313
|
+
*
|
|
314
|
+
* A site's opt-outs are stored on the site and read across its group, so
|
|
315
|
+
* erasing a grouped site erases refusals its siblings honor today, and
|
|
316
|
+
* leaves the declaration naming a site that no longer exists. A site named
|
|
317
|
+
* by a change still in progress is the same hazard in motion: the change
|
|
318
|
+
* may be copying its opt-outs out, or relying on them being there. So the
|
|
319
|
+
* site leaves its group first — which carries its refusals to the sites
|
|
320
|
+
* that stay — and is deleted after.
|
|
321
|
+
*
|
|
322
|
+
* Deleting the whole organization is the one exception, and is the caller's
|
|
323
|
+
* to make: nothing is left to read the refusals.
|
|
324
|
+
*/ export function consentGroupSiteHold(org, hostId) {
|
|
325
|
+
if (!hostId) return null;
|
|
326
|
+
const group = consentGroupForHost(org, hostId);
|
|
327
|
+
if (group.declared) {
|
|
328
|
+
var _group_name;
|
|
329
|
+
return {
|
|
330
|
+
reason: 'grouped',
|
|
331
|
+
groupId: group.groupId,
|
|
332
|
+
name: (_group_name = group.name) != null ? _group_name : ''
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const marker = (org != null ? org : {})[CONSENT_GROUP_CHANGE_MARKER_FIELD];
|
|
336
|
+
const changing = marker == null ? void 0 : marker.hostIds;
|
|
337
|
+
if (Array.isArray(changing) && changing.includes(hostId)) {
|
|
338
|
+
return {
|
|
339
|
+
reason: 'changing'
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
242
344
|
/**
|
|
243
345
|
* The sites whose OPT-OUTS answer for mail sent by `group.hostId`, the
|
|
244
346
|
* sending site first.
|
|
@@ -296,5 +398,58 @@
|
|
|
296
398
|
if (!group.declared || !group.name) return null;
|
|
297
399
|
return `You'll receive marketing email from ${group.name}, which covers ${group.hostIds.length} sites.`;
|
|
298
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* A short fingerprint of the disclosure a capture surface rendered, or
|
|
403
|
+
* `null` when {@link consentGroupDisclosure} has nothing to render.
|
|
404
|
+
*
|
|
405
|
+
* The surface sends it back with the submission, and
|
|
406
|
+
* {@link consentGroupForGrant} pools only when it still matches: a form
|
|
407
|
+
* rendered before a rename, before a site joined, or before the group
|
|
408
|
+
* existed at all carries a key that no longer does, and its grant narrows to
|
|
409
|
+
* the site the person was on rather than reaching sites they were never told
|
|
410
|
+
* about.
|
|
411
|
+
*
|
|
412
|
+
* Over the three things the sentence and the grant stand for — the group's
|
|
413
|
+
* id, its name and its sites, sorted — serialized as JSON so no name can
|
|
414
|
+
* run into an id. FNV-1a over the UTF-16 code units, which keeps it
|
|
415
|
+
* synchronous and identical in the browser and on the server with nothing
|
|
416
|
+
* to import. It is not a secret and proves no identity: it answers "was it
|
|
417
|
+
* THIS disclosure", and a caller willing to lie could as easily lie about
|
|
418
|
+
* the checkbox.
|
|
419
|
+
*/ export function consentGroupDisclosureKey(group) {
|
|
420
|
+
if (!consentGroupDisclosure(group)) return null;
|
|
421
|
+
const text = JSON.stringify([
|
|
422
|
+
group.groupId,
|
|
423
|
+
group.name,
|
|
424
|
+
[
|
|
425
|
+
...group.hostIds
|
|
426
|
+
].sort()
|
|
427
|
+
]);
|
|
428
|
+
let hash = 0x811c9dc5;
|
|
429
|
+
for(let index = 0; index < text.length; index += 1){
|
|
430
|
+
hash ^= text.charCodeAt(index);
|
|
431
|
+
hash = Math.imul(hash, 0x01000193);
|
|
432
|
+
}
|
|
433
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The group a GRANT is recorded for: `group` itself only when it is declared
|
|
437
|
+
* and `disclosureKey` is exactly its current {@link consentGroupDisclosureKey};
|
|
438
|
+
* otherwise the capturing site alone.
|
|
439
|
+
*
|
|
440
|
+
* Called by every door that records a grant, with whatever key the capture
|
|
441
|
+
* surface sent back — none, for every surface that does not render the
|
|
442
|
+
* disclosure. Narrow is the failure direction on purpose: a grant that should
|
|
443
|
+
* have pooled and did not withholds mail from a sibling site, where one that
|
|
444
|
+
* pooled on a sentence nobody saw sends it.
|
|
445
|
+
*
|
|
446
|
+
* Only the grant narrows. The same capture still stamps `visibleTo` from the
|
|
447
|
+
* whole group, and still reads refusals across it: who may SEE a person and
|
|
448
|
+
* who may MAIL them are separate questions — see the module note.
|
|
449
|
+
*/ export function consentGroupForGrant(group, disclosureKey) {
|
|
450
|
+
const expected = consentGroupDisclosureKey(group);
|
|
451
|
+
if (expected !== null && disclosureKey === expected) return group;
|
|
452
|
+
return soloConsentGroup(group.hostId);
|
|
453
|
+
}
|
|
299
454
|
|
|
300
455
|
//# sourceMappingURL=consent-groups.js.map
|