@aglyn/aglyn 1.0.0-beta.196 → 1.0.0-beta.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/consent-group-change.d.ts +519 -0
  3. package/src/lib/app-utils/consent-group-change.js +671 -0
  4. package/src/lib/app-utils/consent-group-change.js.map +1 -0
  5. package/src/lib/app-utils/consent-groups.d.ts +106 -8
  6. package/src/lib/app-utils/consent-groups.js +163 -8
  7. package/src/lib/app-utils/consent-groups.js.map +1 -1
  8. package/src/lib/app-utils/contact-merge.js +37 -4
  9. package/src/lib/app-utils/contact-merge.js.map +1 -1
  10. package/src/lib/app-utils/docs-help.generated.d.ts +7 -7
  11. package/src/lib/app-utils/docs-help.generated.js +13 -0
  12. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  13. package/src/lib/app-utils/docs-index.generated.js +48 -34
  14. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  15. package/src/lib/app-utils/health-report.js +13 -0
  16. package/src/lib/app-utils/health-report.js.map +1 -1
  17. package/src/lib/app-utils/marketing-consent.d.ts +59 -0
  18. package/src/lib/app-utils/marketing-consent.js +86 -0
  19. package/src/lib/app-utils/marketing-consent.js.map +1 -1
  20. package/src/lib/foundation/definitions/org-billing.types.d.ts +22 -3
  21. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  22. package/src/lib/plugin-manager/plugin-consent-group-change.d.ts +116 -0
  23. package/src/lib/plugin-manager/plugin-consent-group-change.js +113 -0
  24. package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -0
  25. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +8 -0
  26. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/consent-groups.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 * CONSENT GROUPS — the declared unit a marketing basis is given to.\n *\n * ## The two organizations this exists to serve at once\n *\n * An AGENCY runs twelve unrelated client brands out of one account. A person\n * who ticked a box on one client's form has agreed to hear from that client\n * and from nobody else, and mailing them on behalf of the other eleven is a\n * consent violation whatever the account structure says.\n *\n * A SINGLE BUSINESS runs three sites — a shop, a booking page, a blog — under\n * one name. It collects addresses on all three and mails them from any of\n * them, because there is one controller and one brand and the visitor knows\n * it. Refusing that is not caution, it is a product that cannot serve a\n * perfectly ordinary customer.\n *\n * The org boundary cannot tell them apart, and neither can anything else the\n * database already holds. So the difference is DECLARED: a group names the\n * sites that are one sender. Undeclared, every site is its own group of one,\n * which is the agency's answer and costs the agency nothing to get right.\n *\n * ## ⛔ NOTHING HERE IS INFERRED\n *\n * Not from shared ownership, not from a shared org, not from a shared sending\n * domain, not from a shared theme. Sharing an account is a billing fact.\n * There is exactly one input — {@link CONSENT_GROUPS_FIELD} on the org — and\n * a site that is not named in it is alone.\n *\n * ## A group must be DISCLOSABLE, so it must have a NAME\n *\n * Pooling is only legitimate if the person was told about it: the checkbox\n * has to say who they will hear from. A group with no display name cannot be\n * rendered on a capture surface, so it cannot be disclosed, so it does not\n * pool — {@link consentGroupForHost} returns the group of one for every site\n * in a nameless group rather than pooling silently. That refusal is the whole\n * mechanism by which \"declared\" is stronger than \"configured\".\n *\n * ## Pooling applies FORWARD only\n *\n * A grant records the group AS DISCLOSED at the moment it was given, in the\n * entry itself, and every covered site gets its own entry. Adding a site to a\n * group therefore reaches captures made after the change and none made\n * before. An org-level switch that widened existing grants would be the leak\n * wearing a different hat: the people already on the list were told a\n * different thing.\n *\n * OPT-OUT runs the other way — read against the CURRENT group, so a site\n * joining a group inherits every refusal already standing against it. The\n * asymmetry is the same one the rest of this area keeps: a permissive fact is\n * written narrowly and read exactly, a restrictive fact is read as broadly as\n * it could possibly apply. Every opt-out the send paths consult is read this\n * way — the site suppression lists, the topic opt-outs and the recipient's\n * cadence (`email-suppression.ts`), and the consent refusal\n * (`marketing-consent.ts`) — and the preference pages lift one across the\n * group, since a person rejoining the sender has rejoined all of it.\n *\n * ## A pending CONFIRMATION is the asking site's, unless the org says the group waits\n *\n * A site that asks for a confirmation click holds its own mail on that stream\n * until the click comes. A pending question is not a refusal, so by default\n * the rule above does not carry it: the sibling sites go on mailing. An org\n * may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every\n * site of a declared group waits for the click the way the asking site does —\n * a pending entry on any of them holds the stream from all of them, read\n * across the CURRENT group exactly as a refusal is.\n *\n * The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),\n * so a send path already holding the group holds the answer and reads nothing\n * more for it. A group of one never waits on anybody: there is nobody else.\n * Off is the default and the absence, and an org that never set it reads\n * exactly the documents it read before the field existed.\n *\n * ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A\n * refusal is stored on the site the person acted on, so once that site is no\n * longer named, its former siblings stop seeing a refusal that was given to\n * them too. Whatever edits this declaration must carry the leaving site's\n * refusals onto the sites that stay — there is no console editor for it yet,\n * and that is the rule the first one has to keep.\n *\n * ## VISIBILITY IS A SEPARATE AXIS\n *\n * Seeing a contact is not permission to mail them. A group decides consent\n * and opt-out; `visibleTo` decides who can read the row, and an org may\n * widen that for reporting while consent stays where it was given. The two\n * are resolved by different functions on purpose, and neither reads the\n * other.\n */\n\nimport type { TopicSubscriptionState } from './email-topics'\nimport { hostScopeToken, MAX_SCOPE_HOSTS, type ScopeToken } from './scope-tokens'\n\n/**\n * The org field holding the declarations:\n * `{ [groupId]: { name, hostIds } }`.\n *\n * On the org rather than on each host, because a group is a statement ABOUT\n * a set and a set stored as N per-host pointers can disagree with itself —\n * site A naming a group that site B has left is a half-declared controller,\n * and there is no reading of that which is safe to pool on.\n */\nexport const CONSENT_GROUPS_FIELD = 'consentGroups'\n\n/**\n * The org field that makes a declared group's sites wait for each other's\n * confirmation click (AGL-3316): `true` is on; absent is off.\n *\n * One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a\n * flag on each declaration, because a declaration decides WHICH sites are one\n * sender and this decides how the org treats a question one of them has put.\n * Written only by `/api/orgs/settings`; the rules deny both fields to every\n * client.\n */\nexport const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD =\n 'consentGroupsAwaitConfirmation'\n\n/**\n * The most sites one group may name.\n *\n * The same ceiling `visibleTo` carries, and for a related reason: the send\n * path reads a group's opt-out lists in one `getAll`, so the group multiplies\n * that round trip's size. It is also a sanity bound on a disclosure — a\n * checkbox that has to name forty brands has not disclosed anything.\n */\nexport const MAX_CONSENT_GROUP_HOSTS = MAX_SCOPE_HOSTS\n\n/** One declared group, as stored. */\nexport interface StoredConsentGroup {\n /** Shown on the capture surface. Without it the group cannot pool. */\n name: string\n /** The sites that are one sender. */\n hostIds: string[]\n}\n\n/**\n * The group a host belongs to, resolved FOR that host.\n *\n * It carries the asking site as well as the set, which is what lets one value\n * be passed where both are needed. Every consent read needs both — the grant\n * is looked up under the asking site, while a refusal is honored across the\n * whole group — and two arguments that must agree is two arguments that can\n * disagree.\n */\nexport interface ConsentGroup {\n /** The site this was resolved for. Always a member of {@link hostIds}. */\n hostId: string\n /**\n * The group's id, or the host's own id for an undeclared site.\n *\n * Recorded on every grant so a stored basis says which controller it was\n * given to even after the declaration changes.\n */\n groupId: string\n /**\n * What the capture surface must display. `null` for a group of one, where\n * the site's own name is what a form already shows and inventing a second\n * one would put a name in front of a person that nothing else uses.\n */\n name: string | null\n /** Every site covered, sorted, always including the host asked about. */\n hostIds: string[]\n /** False for the implicit group of one. */\n declared: boolean\n /**\n * Whether a confirmation one site of the group is waiting on holds the\n * other sites' mail too — the org's\n * {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD}, for a declared group.\n * Always `false` for a group of one, which has no other site to hold.\n */\n awaitsConfirmation: boolean\n}\n\n/**\n * The group of one — a site that has declared no pooling.\n *\n * Exported because it is a value callers state deliberately: a writer that\n * means \"this site only\" says so, rather than passing a host id and letting\n * a helper decide what it covers.\n */\nexport function soloConsentGroup(hostId: string): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n return {\n hostId,\n groupId: hostId,\n name: null,\n hostIds: [hostId],\n declared: false,\n awaitsConfirmation: false,\n }\n}\n\n/**\n * Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.\n *\n * Only a stored `true` is on. Absent, `false`, or anything a stray write left\n * is off — the state every org was in before the field existed.\n */\nexport function consentGroupsAwaitConfirmation(\n org: Record<string, unknown> | null | undefined,\n): boolean {\n return (org ?? {})[CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD] === true\n}\n\n/**\n * Reads the declared groups off an org document, dropping every entry that\n * cannot be honored.\n *\n * A malformed declaration reads as ABSENT, never as a wider group: the whole\n * value of this field is that pooling is deliberate, and a corrupt value must\n * not be a way to reach an audience nobody declared.\n *\n * The four refusals, each of which would otherwise pool without a disclosure:\n *\n * - **no usable name** — cannot be shown on a form, so cannot be disclosed;\n * - **fewer than two sites** — not a pooling declaration, and a group of one\n * is what an undeclared site already gets;\n * - **over {@link MAX_CONSENT_GROUP_HOSTS}** — see that constant;\n * - **a site claimed by two groups** — two controllers claiming one site is\n * a contradiction, not a wider group, so BOTH claims are dropped and the\n * site falls back to being alone.\n */\nexport function readConsentGroups(\n org: Record<string, unknown> | null | undefined,\n): Record<string, StoredConsentGroup> {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const usable: Record<string, StoredConsentGroup> = {}\n for (const [groupId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!groupId || !value || typeof value !== 'object') continue\n const group = value as Record<string, unknown>\n const name = typeof group['name'] === 'string' ? group['name'].trim() : ''\n if (!name) continue\n const hostIds = Array.isArray(group['hostIds'])\n ? [\n ...new Set(\n (group['hostIds'] as unknown[])\n .map((id) => String(id ?? '').trim())\n .filter(Boolean),\n ),\n ].sort()\n : []\n if (hostIds.length < 2 || hostIds.length > MAX_CONSENT_GROUP_HOSTS) continue\n usable[groupId] = { name, hostIds }\n }\n /*\n * The overlap pass, second because it needs every survivor of the first.\n * A site named by two groups is dropped from BOTH — the alternative is\n * picking one, and a coin flip is not a declaration.\n */\n const claims = new Map<string, number>()\n for (const group of Object.values(usable)) {\n for (const hostId of group.hostIds) {\n claims.set(hostId, (claims.get(hostId) ?? 0) + 1)\n }\n }\n const contested = new Set(\n [...claims.entries()].filter(([, count]) => count > 1).map(([id]) => id),\n )\n if (!contested.size) return usable\n const settled: Record<string, StoredConsentGroup> = {}\n for (const [groupId, group] of Object.entries(usable)) {\n if (group.hostIds.some((hostId) => contested.has(hostId))) continue\n settled[groupId] = group\n }\n return settled\n}\n\n/**\n * The group `hostId` belongs to — the declared one, or the group of one.\n *\n * The ONLY way any caller learns what a site's consent covers. A caller that\n * built its own set from `org.hosts`, or from a sending domain, or from a\n * theme, would be inferring a controller, which is the thing this module\n * exists to make impossible.\n */\nexport function consentGroupForHost(\n org: Record<string, unknown> | null | undefined,\n hostId: string,\n): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n for (const [groupId, group] of Object.entries(readConsentGroups(org))) {\n if (!group.hostIds.includes(hostId)) continue\n return {\n hostId,\n groupId,\n name: group.name,\n hostIds: [...group.hostIds],\n declared: true,\n awaitsConfirmation: consentGroupsAwaitConfirmation(org),\n }\n }\n return soloConsentGroup(hostId)\n}\n\n/**\n * The sites whose OPT-OUTS answer for mail sent by `group.hostId`, the\n * sending site first.\n *\n * Every site the group names, because a refusal filed against any of them is\n * a refusal of the sender — see \"OPT-OUT runs the other way\" above. The\n * sending site leads so a reader that treats its own record differently from\n * a sibling's (a sibling's pending confirmation holds the send only when the\n * group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by\n * position. A group of one is the site alone.\n */\nexport function consentGroupOptOutHosts(\n group: Pick<ConsentGroup, 'hostId' | 'hostIds'>,\n): string[] {\n const siblings = (group.hostIds ?? []).filter(\n (id) => typeof id === 'string' && id && id !== group.hostId,\n )\n return [group.hostId, ...new Set(siblings)]\n}\n\n/**\n * The sending site's standing on one stream, read across its group.\n *\n * `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,\n * in that order, so the sending site's comes first. A refusal on any of them\n * is a refusal of the sender. The sending site's own pending confirmation\n * holds its mail; a sibling's holds it only when the group\n * {@link ConsentGroup.awaitsConfirmation}. The precedence is the one\n * `readTopicSubscriptionState` keeps within one entry: a refusal outranks a\n * pending question, and an expired question is still pending.\n */\nexport function consentGroupTopicState(\n group: Pick<ConsentGroup, 'awaitsConfirmation'>,\n states: readonly TopicSubscriptionState[],\n): TopicSubscriptionState {\n const [own = 'subscribed', ...siblings] = states\n if (own === 'opted-out' || siblings.includes('opted-out')) return 'opted-out'\n if (own === 'pending') return 'pending'\n return group.awaitsConfirmation === true && siblings.includes('pending')\n ? 'pending'\n : 'subscribed'\n}\n\n/**\n * The `visibleTo` a resource captured by this group starts with.\n *\n * A separate function from anything above, called by the capture doors and\n * by nothing that decides mailability, because visibility and consent are\n * different questions with different answers. A group of one produces\n * `['host:{id}']` — the isolation an agency needs, arrived at without the\n * agency configuring anything.\n */\nexport function consentGroupScope(group: ConsentGroup): ScopeToken[] {\n return group.hostIds.map(hostScopeToken)\n}\n\n/**\n * The sentence a capture surface must show beside the checkbox, or `null`\n * when the site's own name already says it.\n *\n * Returned as text rather than a boolean so that the disclosure and the\n * grant come from ONE resolved group: a form that rendered its own wording\n * from a separate lookup could show one set of brands and record another,\n * and the recorded set is the one that decides who mails this person.\n */\nexport function consentGroupDisclosure(group: ConsentGroup): string | null {\n if (!group.declared || !group.name) return null\n return `You'll receive marketing email from ${group.name}, which covers ${group.hostIds.length} sites.`\n}\n"],"names":["hostScopeToken","MAX_SCOPE_HOSTS","CONSENT_GROUPS_FIELD","CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD","MAX_CONSENT_GROUP_HOSTS","soloConsentGroup","hostId","Error","groupId","name","hostIds","declared","awaitsConfirmation","consentGroupsAwaitConfirmation","org","readConsentGroups","raw","Array","isArray","usable","value","Object","entries","group","trim","Set","map","id","String","filter","Boolean","sort","length","claims","Map","values","set","get","contested","count","size","settled","some","has","consentGroupForHost","includes","consentGroupOptOutHosts","siblings","consentGroupTopicState","states","own","consentGroupScope","consentGroupDisclosure"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/consent-groups.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 * CONSENT GROUPS — the declared unit a marketing basis is given to.\n *\n * ## The two organizations this exists to serve at once\n *\n * An AGENCY runs twelve unrelated client brands out of one account. A person\n * who ticked a box on one client's form has agreed to hear from that client\n * and from nobody else, and mailing them on behalf of the other eleven is a\n * consent violation whatever the account structure says.\n *\n * A SINGLE BUSINESS runs three sites — a shop, a booking page, a blog — under\n * one name. It collects addresses on all three and mails them from any of\n * them, because there is one controller and one brand and the visitor knows\n * it. Refusing that is not caution, it is a product that cannot serve a\n * perfectly ordinary customer.\n *\n * The org boundary cannot tell them apart, and neither can anything else the\n * database already holds. So the difference is DECLARED: a group names the\n * sites that are one sender. Undeclared, every site is its own group of one,\n * which is the agency's answer and costs the agency nothing to get right.\n *\n * ## ⛔ NOTHING HERE IS INFERRED\n *\n * Not from shared ownership, not from a shared org, not from a shared sending\n * domain, not from a shared theme. Sharing an account is a billing fact.\n * There is exactly one input — {@link CONSENT_GROUPS_FIELD} on the org — and\n * a site that is not named in it is alone.\n *\n * ## A group must be DISCLOSABLE, so it must have a NAME\n *\n * Pooling is only legitimate if the person was told about it: the checkbox\n * has to say who they will hear from. A group with no display name cannot be\n * rendered on a capture surface, so it cannot be disclosed, so it does not\n * pool — {@link consentGroupForHost} returns the group of one for every site\n * in a nameless group rather than pooling silently. That refusal is the whole\n * mechanism by which \"declared\" is stronger than \"configured\".\n *\n * ## …and a grant pools only where the name was actually SHOWN\n *\n * Disclosable is not disclosed. A form rendered before the group existed, a\n * checkout that has never displayed the sentence, and a page cached across a\n * rename all capture under a declaration their visitor never read. So a\n * capture surface that renders {@link consentGroupDisclosure} sends back the\n * {@link consentGroupDisclosureKey} of what it rendered, and\n * {@link consentGroupForGrant} pools the grant only when that key matches the\n * group as it stands. Anything else — no key, a stale key, a surface that\n * never renders the sentence — records the capturing site alone. Visibility\n * is not narrowed with it: that axis follows the group, see below.\n *\n * ## Pooling applies FORWARD only\n *\n * A grant records the group AS DISCLOSED at the moment it was given, in the\n * entry itself, and every covered site gets its own entry. Adding a site to a\n * group therefore reaches captures made after the change and none made\n * before. An org-level switch that widened existing grants would be the leak\n * wearing a different hat: the people already on the list were told a\n * different thing. The same holds for a grant carried from one record to\n * another — a list enrollment, a lead becoming a contact: it is copied as it\n * was recorded, never re-derived from the group as it stands now.\n *\n * OPT-OUT runs the other way — read against the CURRENT group, so a site\n * joining a group inherits every refusal already standing against it. The\n * asymmetry is the same one the rest of this area keeps: a permissive fact is\n * written narrowly and read exactly, a restrictive fact is read as broadly as\n * it could possibly apply. Every opt-out the send paths consult is read this\n * way — the site suppression lists, the topic opt-outs and the recipient's\n * cadence (`email-suppression.ts`), and the consent refusal\n * (`marketing-consent.ts`) — and the preference pages lift one across the\n * group, since a person rejoining the sender has rejoined all of it.\n *\n * ## A pending CONFIRMATION is the asking site's, unless the org says the group waits\n *\n * A site that asks for a confirmation click holds its own mail on that stream\n * until the click comes. A pending question is not a refusal, so by default\n * the rule above does not carry it: the sibling sites go on mailing. An org\n * may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every\n * site of a declared group waits for the click the way the asking site does —\n * a pending entry on any of them holds the stream from all of them, read\n * across the CURRENT group exactly as a refusal is.\n *\n * The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),\n * so a send path already holding the group holds the answer and reads nothing\n * more for it. A group of one never waits on anybody: there is nobody else.\n * Off is the default and the absence, and an org that never set it reads\n * exactly the documents it read before the field existed.\n *\n * ⚠️ The one change that runs AGAINST reading is sites SEPARATING — a site\n * leaving a group, moving to another, or a group dissolving. A refusal is\n * stored on the site the person acted on, so once two sites stop being one\n * sender, each stops seeing the refusals filed against the other, and every\n * one of them was a refusal of the sender both sites were at the time. So a\n * change to this declaration carries them BOTH WAYS before it takes effect:\n * the sites that stay keep every opt-out filed on the site that leaves, and\n * the site that leaves keeps every opt-out filed on the sites it leaves —\n * the preference page promised the person that an opt-out from the group\n * covers all of it, and separating the sites must not quietly break that.\n * The consent group change (the pure plan in `consent-group-change.ts`, run\n * by the executor in `@aglyn/tenant-data-admin`, started from the Emails\n * hub's Consent groups section through `POST /api/orgs/consent-groups`) is\n * the one writer of this field, and it carries the site suppression rows,\n * the topic opt-outs, the pace and a contact's per-site refusal before it\n * flips the declaration.\n * A site that is in a group, or in a change still running, cannot be\n * deleted until it is out: deletion would destroy refusals a sibling still\n * reads.\n *\n * ## VISIBILITY IS A SEPARATE AXIS\n *\n * Seeing a contact is not permission to mail them. A group decides consent\n * and opt-out; `visibleTo` decides who can read the row, and an org may\n * widen that for reporting while consent stays where it was given. The two\n * are resolved by different functions on purpose, and neither reads the\n * other.\n */\n\nimport type { TopicSubscriptionState } from './email-topics'\nimport { hostScopeToken, MAX_SCOPE_HOSTS, type ScopeToken } from './scope-tokens'\n\n/**\n * The org field holding the declarations:\n * `{ [groupId]: { name, hostIds } }`.\n *\n * On the org rather than on each host, because a group is a statement ABOUT\n * a set and a set stored as N per-host pointers can disagree with itself —\n * site A naming a group that site B has left is a half-declared controller,\n * and there is no reading of that which is safe to pool on.\n */\nexport const CONSENT_GROUPS_FIELD = 'consentGroups'\n\n/**\n * The org field that makes a declared group's sites wait for each other's\n * confirmation click (AGL-3316): `true` is on; absent is off.\n *\n * One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a\n * flag on each declaration, because a declaration decides WHICH sites are one\n * sender and this decides how the org treats a question one of them has put.\n * Written only by `/api/orgs/settings`; the rules deny both fields to every\n * client.\n */\nexport const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD =\n 'consentGroupsAwaitConfirmation'\n\n/**\n * The most sites one group may name.\n *\n * The same ceiling `visibleTo` carries, and for a related reason: the send\n * path reads a group's opt-out lists in one `getAll`, so the group multiplies\n * that round trip's size. It is also a sanity bound on a disclosure — a\n * checkbox that has to name forty brands has not disclosed anything.\n */\nexport const MAX_CONSENT_GROUP_HOSTS = MAX_SCOPE_HOSTS\n\n/** One declared group, as stored. */\nexport interface StoredConsentGroup {\n /** Shown on the capture surface. Without it the group cannot pool. */\n name: string\n /** The sites that are one sender. */\n hostIds: string[]\n}\n\n/**\n * The group a host belongs to, resolved FOR that host.\n *\n * It carries the asking site as well as the set, which is what lets one value\n * be passed where both are needed. Every consent read needs both — the grant\n * is looked up under the asking site, while a refusal is honored across the\n * whole group — and two arguments that must agree is two arguments that can\n * disagree.\n */\nexport interface ConsentGroup {\n /** The site this was resolved for. Always a member of {@link hostIds}. */\n hostId: string\n /**\n * The group's id, or the host's own id for an undeclared site.\n *\n * Recorded on every grant so a stored basis says which controller it was\n * given to even after the declaration changes.\n */\n groupId: string\n /**\n * What the capture surface must display. `null` for a group of one, where\n * the site's own name is what a form already shows and inventing a second\n * one would put a name in front of a person that nothing else uses.\n */\n name: string | null\n /** Every site covered, sorted, always including the host asked about. */\n hostIds: string[]\n /** False for the implicit group of one. */\n declared: boolean\n /**\n * Whether a confirmation one site of the group is waiting on holds the\n * other sites' mail too — the org's\n * {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD}, for a declared group.\n * Always `false` for a group of one, which has no other site to hold.\n */\n awaitsConfirmation: boolean\n}\n\n/**\n * The group of one — a site that has declared no pooling.\n *\n * Exported because it is a value callers state deliberately: a writer that\n * means \"this site only\" says so, rather than passing a host id and letting\n * a helper decide what it covers.\n */\nexport function soloConsentGroup(hostId: string): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n return {\n hostId,\n groupId: hostId,\n name: null,\n hostIds: [hostId],\n declared: false,\n awaitsConfirmation: false,\n }\n}\n\n/**\n * Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.\n *\n * Only a stored `true` is on. Absent, `false`, or anything a stray write left\n * is off — the state every org was in before the field existed.\n */\nexport function consentGroupsAwaitConfirmation(\n org: Record<string, unknown> | null | undefined,\n): boolean {\n return (org ?? {})[CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD] === true\n}\n\n/**\n * Reads the declared groups off an org document, dropping every entry that\n * cannot be honored.\n *\n * A malformed declaration reads as ABSENT, never as a wider group: the whole\n * value of this field is that pooling is deliberate, and a corrupt value must\n * not be a way to reach an audience nobody declared.\n *\n * The five refusals, each of which would otherwise pool without a disclosure\n * or file one sender's records under another's key:\n *\n * - **no usable name** — cannot be shown on a form, so cannot be disclosed;\n * - **fewer than two sites** — not a pooling declaration, and a group of one\n * is what an undeclared site already gets;\n * - **over {@link MAX_CONSENT_GROUP_HOSTS}** — see that constant;\n * - **an id that is a site's id** — a site that declared nothing uses its\n * own id as its group id, so a group spelled the same way would share\n * that site's key: its grant stamps, its opt-out lookups and the records\n * the CRM files under the group id would read as the lone site's, and the\n * lone site's as the group's. Checked against every site the org holds\n * and every site any entry names, usable or not;\n * - **a site claimed by two groups** — two controllers claiming one site is\n * a contradiction, not a wider group, so BOTH claims are dropped and the\n * site falls back to being alone.\n */\nexport function readConsentGroups(\n org: Record<string, unknown> | null | undefined,\n): Record<string, StoredConsentGroup> {\n const raw = (org ?? {})[CONSENT_GROUPS_FIELD]\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const siteIds = consentGroupSiteIds(org, raw as Record<string, unknown>)\n const usable: Record<string, StoredConsentGroup> = {}\n for (const [groupId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!groupId || !value || typeof value !== 'object') continue\n if (siteIds.has(groupId)) continue\n const group = value as Record<string, unknown>\n const name = typeof group['name'] === 'string' ? group['name'].trim() : ''\n if (!name) continue\n const hostIds = Array.isArray(group['hostIds'])\n ? [\n ...new Set(\n (group['hostIds'] as unknown[])\n .map((id) => String(id ?? '').trim())\n .filter(Boolean),\n ),\n ].sort()\n : []\n if (hostIds.length < 2 || hostIds.length > MAX_CONSENT_GROUP_HOSTS) continue\n usable[groupId] = { name, hostIds }\n }\n /*\n * The overlap pass, second because it needs every survivor of the first.\n * A site named by two groups is dropped from BOTH — the alternative is\n * picking one, and a coin flip is not a declaration.\n */\n const claims = new Map<string, number>()\n for (const group of Object.values(usable)) {\n for (const hostId of group.hostIds) {\n claims.set(hostId, (claims.get(hostId) ?? 0) + 1)\n }\n }\n const contested = new Set(\n [...claims.entries()].filter(([, count]) => count > 1).map(([id]) => id),\n )\n if (!contested.size) return usable\n const settled: Record<string, StoredConsentGroup> = {}\n for (const [groupId, group] of Object.entries(usable)) {\n if (group.hostIds.some((hostId) => contested.has(hostId))) continue\n settled[groupId] = group\n }\n return settled\n}\n\n/**\n * Every id that names a site as far as the declaration can tell: the org's\n * own `hosts` and every site any raw entry names, including the entries the\n * other refusals will drop.\n *\n * Wider than the usable set on purpose. A group id that collides with a\n * site the org has not linked yet, or with a site named only by a broken\n * entry, is the same collision one edit later, and refusing it now costs a\n * declaration nothing a valid one would have.\n */\nfunction consentGroupSiteIds(\n org: Record<string, unknown> | null | undefined,\n raw: Record<string, unknown>,\n): Set<string> {\n const ids = new Set<string>()\n const hosts = (org ?? {})['hosts']\n if (Array.isArray(hosts)) {\n for (const id of hosts) if (typeof id === 'string' && id) ids.add(id)\n } else if (hosts && typeof hosts === 'object') {\n for (const id of Object.keys(hosts)) if (id) ids.add(id)\n }\n for (const value of Object.values(raw)) {\n const hostIds = (value as { hostIds?: unknown } | null)?.hostIds\n if (!Array.isArray(hostIds)) continue\n for (const id of hostIds) {\n const trimmed = String(id ?? '').trim()\n if (trimmed) ids.add(trimmed)\n }\n }\n return ids\n}\n\n/**\n * The group `hostId` belongs to — the declared one, or the group of one.\n *\n * The ONLY way any caller learns what a site's consent covers. A caller that\n * built its own set from `org.hosts`, or from a sending domain, or from a\n * theme, would be inferring a controller, which is the thing this module\n * exists to make impossible.\n */\nexport function consentGroupForHost(\n org: Record<string, unknown> | null | undefined,\n hostId: string,\n): ConsentGroup {\n if (!hostId) {\n throw new Error('[consent-groups] a consent group must name a site')\n }\n for (const [groupId, group] of Object.entries(readConsentGroups(org))) {\n if (!group.hostIds.includes(hostId)) continue\n return {\n hostId,\n groupId,\n name: group.name,\n hostIds: [...group.hostIds],\n declared: true,\n awaitsConfirmation: consentGroupsAwaitConfirmation(org),\n }\n }\n return soloConsentGroup(hostId)\n}\n\n/**\n * The org field a running consent group change marks itself with:\n * `{ changeId, phase, hostIds, … }`, present from the moment the change\n * starts until it has finished. Only its `hostIds` is read here.\n */\nconst CONSENT_GROUP_CHANGE_MARKER_FIELD = 'consentGroupsChange'\n\n/** Why a site may not be deleted yet — see {@link consentGroupSiteHold}. */\nexport type ConsentGroupSiteHold =\n /** The site is one sender with other sites, under this group. */\n | { reason: 'grouped'; groupId: string; name: string }\n /** A consent group change naming the site has not finished. */\n | { reason: 'changing' }\n\n/**\n * Whether deleting `hostId` would take refusals its consent group still\n * needs, and why — or `null` when the site may go (AGL-3320).\n *\n * A site's opt-outs are stored on the site and read across its group, so\n * erasing a grouped site erases refusals its siblings honor today, and\n * leaves the declaration naming a site that no longer exists. A site named\n * by a change still in progress is the same hazard in motion: the change\n * may be copying its opt-outs out, or relying on them being there. So the\n * site leaves its group first — which carries its refusals to the sites\n * that stay — and is deleted after.\n *\n * Deleting the whole organization is the one exception, and is the caller's\n * to make: nothing is left to read the refusals.\n */\nexport function consentGroupSiteHold(\n org: Record<string, unknown> | null | undefined,\n hostId: string,\n): ConsentGroupSiteHold | null {\n if (!hostId) return null\n const group = consentGroupForHost(org, hostId)\n if (group.declared) {\n return { reason: 'grouped', groupId: group.groupId, name: group.name ?? '' }\n }\n const marker = (org ?? {})[CONSENT_GROUP_CHANGE_MARKER_FIELD]\n const changing = (marker as { hostIds?: unknown } | null | undefined)?.hostIds\n if (Array.isArray(changing) && changing.includes(hostId)) {\n return { reason: 'changing' }\n }\n return null\n}\n\n/**\n * The sites whose OPT-OUTS answer for mail sent by `group.hostId`, the\n * sending site first.\n *\n * Every site the group names, because a refusal filed against any of them is\n * a refusal of the sender — see \"OPT-OUT runs the other way\" above. The\n * sending site leads so a reader that treats its own record differently from\n * a sibling's (a sibling's pending confirmation holds the send only when the\n * group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by\n * position. A group of one is the site alone.\n */\nexport function consentGroupOptOutHosts(\n group: Pick<ConsentGroup, 'hostId' | 'hostIds'>,\n): string[] {\n const siblings = (group.hostIds ?? []).filter(\n (id) => typeof id === 'string' && id && id !== group.hostId,\n )\n return [group.hostId, ...new Set(siblings)]\n}\n\n/**\n * The sending site's standing on one stream, read across its group.\n *\n * `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,\n * in that order, so the sending site's comes first. A refusal on any of them\n * is a refusal of the sender. The sending site's own pending confirmation\n * holds its mail; a sibling's holds it only when the group\n * {@link ConsentGroup.awaitsConfirmation}. The precedence is the one\n * `readTopicSubscriptionState` keeps within one entry: a refusal outranks a\n * pending question, and an expired question is still pending.\n */\nexport function consentGroupTopicState(\n group: Pick<ConsentGroup, 'awaitsConfirmation'>,\n states: readonly TopicSubscriptionState[],\n): TopicSubscriptionState {\n const [own = 'subscribed', ...siblings] = states\n if (own === 'opted-out' || siblings.includes('opted-out')) return 'opted-out'\n if (own === 'pending') return 'pending'\n return group.awaitsConfirmation === true && siblings.includes('pending')\n ? 'pending'\n : 'subscribed'\n}\n\n/**\n * The `visibleTo` a resource captured by this group starts with.\n *\n * A separate function from anything above, called by the capture doors and\n * by nothing that decides mailability, because visibility and consent are\n * different questions with different answers. A group of one produces\n * `['host:{id}']` — the isolation an agency needs, arrived at without the\n * agency configuring anything.\n */\nexport function consentGroupScope(group: ConsentGroup): ScopeToken[] {\n return group.hostIds.map(hostScopeToken)\n}\n\n/**\n * The sentence a capture surface must show beside the checkbox, or `null`\n * when the site's own name already says it.\n *\n * Returned as text rather than a boolean so that the disclosure and the\n * grant come from ONE resolved group: a form that rendered its own wording\n * from a separate lookup could show one set of brands and record another,\n * and the recorded set is the one that decides who mails this person.\n */\nexport function consentGroupDisclosure(group: ConsentGroup): string | null {\n if (!group.declared || !group.name) return null\n return `You'll receive marketing email from ${group.name}, which covers ${group.hostIds.length} sites.`\n}\n\n/**\n * A short fingerprint of the disclosure a capture surface rendered, or\n * `null` when {@link consentGroupDisclosure} has nothing to render.\n *\n * The surface sends it back with the submission, and\n * {@link consentGroupForGrant} pools only when it still matches: a form\n * rendered before a rename, before a site joined, or before the group\n * existed at all carries a key that no longer does, and its grant narrows to\n * the site the person was on rather than reaching sites they were never told\n * about.\n *\n * Over the three things the sentence and the grant stand for — the group's\n * id, its name and its sites, sorted — serialized as JSON so no name can\n * run into an id. FNV-1a over the UTF-16 code units, which keeps it\n * synchronous and identical in the browser and on the server with nothing\n * to import. It is not a secret and proves no identity: it answers \"was it\n * THIS disclosure\", and a caller willing to lie could as easily lie about\n * the checkbox.\n */\nexport function consentGroupDisclosureKey(\n group: ConsentGroup,\n): string | null {\n if (!consentGroupDisclosure(group)) return null\n const text = JSON.stringify([\n group.groupId,\n group.name,\n [...group.hostIds].sort(),\n ])\n let hash = 0x811c9dc5\n for (let index = 0; index < text.length; index += 1) {\n hash ^= text.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0).toString(16).padStart(8, '0')\n}\n\n/**\n * The group a GRANT is recorded for: `group` itself only when it is declared\n * and `disclosureKey` is exactly its current {@link consentGroupDisclosureKey};\n * otherwise the capturing site alone.\n *\n * Called by every door that records a grant, with whatever key the capture\n * surface sent back — none, for every surface that does not render the\n * disclosure. Narrow is the failure direction on purpose: a grant that should\n * have pooled and did not withholds mail from a sibling site, where one that\n * pooled on a sentence nobody saw sends it.\n *\n * Only the grant narrows. The same capture still stamps `visibleTo` from the\n * whole group, and still reads refusals across it: who may SEE a person and\n * who may MAIL them are separate questions — see the module note.\n */\nexport function consentGroupForGrant(\n group: ConsentGroup,\n disclosureKey: string | null | undefined,\n): ConsentGroup {\n const expected = consentGroupDisclosureKey(group)\n if (expected !== null && disclosureKey === expected) return group\n return soloConsentGroup(group.hostId)\n}\n"],"names":["hostScopeToken","MAX_SCOPE_HOSTS","CONSENT_GROUPS_FIELD","CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD","MAX_CONSENT_GROUP_HOSTS","soloConsentGroup","hostId","Error","groupId","name","hostIds","declared","awaitsConfirmation","consentGroupsAwaitConfirmation","org","readConsentGroups","raw","Array","isArray","siteIds","consentGroupSiteIds","usable","value","Object","entries","has","group","trim","Set","map","id","String","filter","Boolean","sort","length","claims","Map","values","set","get","contested","count","size","settled","some","ids","hosts","add","keys","trimmed","consentGroupForHost","includes","CONSENT_GROUP_CHANGE_MARKER_FIELD","consentGroupSiteHold","reason","marker","changing","consentGroupOptOutHosts","siblings","consentGroupTopicState","states","own","consentGroupScope","consentGroupDisclosure","consentGroupDisclosureKey","text","JSON","stringify","hash","index","charCodeAt","Math","imul","toString","padStart","consentGroupForGrant","disclosureKey","expected"],"mappings":"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"}
@@ -58,7 +58,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
58
58
  * the rule above needs.
59
59
  */ import { CONTACT_ALTERNATE_EMAILS_CAP, CONTACT_ALTERNATE_EMAILS_FIELD, CONTACT_FACETS_FIELD, CONTACT_FORM_IDS_CAP, CONTACT_FORM_IDS_FIELD, CONTACT_INTERACTIONS_CAP, contactDisplayName, contactEmails, normalizeContactEmail, readContactFacet } from "./contacts.js";
60
60
  import { CONTACT_COMPANY_IDS_FIELD, CONTACT_LIFECYCLE_STAGE_LABELS } from "./crm.js";
61
- import { MARKETING_CONSENT_BY_HOST_FIELD } from "./marketing-consent.js";
61
+ import { MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD } from "./marketing-consent.js";
62
62
  import { nameSearchFields } from "./name-search.js";
63
63
  /** The tags cap every writer applies, so a union cannot exceed it. */ const TAGS_CAP = 20;
64
64
  /** The notes cap the record page applies to its About box. */ const NOTES_CAP = 5000;
@@ -206,6 +206,29 @@ function facetsOf(doc) {
206
206
  function mapOf(value) {
207
207
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
208
208
  }
209
+ /**
210
+ * A refusal one record holds for a site, written over the other record's grant
211
+ * for the same site (AGL-3320).
212
+ *
213
+ * The refusal outranks the grant, as it does when one person's refusal and
214
+ * grant sit on two sites of one group: two records of one person who said no
215
+ * to a site are a person who said no to it, and a merge that kept the grant
216
+ * would start mailing them. The grant is kept beside it as `supersededEntry`,
217
+ * so what the survivor held is still on the record.
218
+ *
219
+ * The merge is written with a merging `set`, which keeps every field of the
220
+ * survivor's entry it does not name — so the grant's own fields are named
221
+ * here, empty, or the refusal would read as carrying the grant's disclosure
222
+ * and whoever recorded it.
223
+ */ function refusalOver(refusal, grant) {
224
+ const entry = _extends({}, refusal, {
225
+ supersededEntry: grant
226
+ });
227
+ for (const key of Object.keys(grant)){
228
+ if (!(key in entry)) entry[key] = null;
229
+ }
230
+ return entry;
231
+ }
209
232
  /**
210
233
  * The plan for folding `merged` into `survivor`.
211
234
  *
@@ -264,9 +287,19 @@ function mapOf(value) {
264
287
  if (!isEmpty(survivor['custom']) || !isEmpty(merged['custom'])) {
265
288
  out['custom'] = mergeCustom(survivor['custom'], merged['custom']);
266
289
  }
267
- // Consent: a grant fills where the survivor has none for that site; a
268
- // refusal on either record stands.
269
- const consentByHost = _extends({}, mapOf(merged[MARKETING_CONSENT_BY_HOST_FIELD]), mapOf(survivor[MARKETING_CONSENT_BY_HOST_FIELD]));
290
+ // Consent: a grant fills where the survivor has none for that site, and a
291
+ // refusal on either record stands — per site as well as unscoped (AGL-3320).
292
+ const survivorConsent = mapOf(survivor[MARKETING_CONSENT_BY_HOST_FIELD]);
293
+ const mergedConsent = mapOf(merged[MARKETING_CONSENT_BY_HOST_FIELD]);
294
+ const consentByHost = _extends({}, mergedConsent, survivorConsent);
295
+ for (const [hostId, value] of Object.entries(mergedConsent)){
296
+ const refusal = mapOf(value);
297
+ if (refusal[MARKETING_CONSENT_FIELD] !== false) continue;
298
+ if (!(hostId in survivorConsent)) continue;
299
+ const standing = mapOf(survivorConsent[hostId]);
300
+ if (standing[MARKETING_CONSENT_FIELD] === false) continue;
301
+ consentByHost[hostId] = refusalOver(refusal, standing);
302
+ }
270
303
  if (Object.keys(consentByHost).length) {
271
304
  out[MARKETING_CONSENT_BY_HOST_FIELD] = consentByHost;
272
305
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-merge.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 * MERGING TWO CONTACT DOCUMENTS INTO ONE (AGL-2625) — the pure half.\n *\n * Contacts are keyed by email, so exact duplicates cannot exist; one human\n * with `jane@acme.com` and `jane@gmail.com` is two documents, with tags,\n * deals, tasks and a timeline split between them. A merge folds the second\n * into the first: the SURVIVOR keeps its address as the identity, the MERGED\n * record's address becomes an alternate on it, and every field is combined\n * by one rule the dialog can show before the write happens.\n *\n * ## The rule\n *\n * - A scalar — a name, a phone, a title, a stage, an owner — is the\n * survivor's where the survivor has one, and fills from the merged record\n * where the survivor is empty. Nothing the survivor holds is overwritten.\n * - A list — tags, campaign filings, the timeline, the sites that captured\n * the person, the forms, the companies — is the UNION, deduplicated and\n * bounded where the write paths bound it.\n * - A figure that counts something — orders, lifetime value, refunds — is\n * the SUM: two records of one person's purchases are one person's\n * purchases. A \"first\" is the earlier, a \"last\" is the later.\n * - Notes are appended, survivor first: a note is a list of sentences, and\n * dropping the merged record's would be the one silent loss in a merge\n * that is otherwise lossless.\n * - A recorded REFUSAL of marketing mail on either record stands on the\n * survivor. The merge asserts the two are one person, and that person\n * said no.\n *\n * Applied per holder: each group's facet on the merged record is folded\n * into the same group's facet on the survivor, and a group only the merged\n * record had comes across whole. No holder's records reach another holder's\n * facet, which is the guarantee the facet shape exists for.\n *\n * ## Why this is a plan and not a write\n *\n * The console shows the two records side by side and says which value wins\n * before anything is committed; the server performs the write. Both read\n * this function, so the preview cannot show one outcome and the write land\n * another. The plan is plain values — no sentinels — so it can be rendered,\n * and it is shaped for a merge-`set`, which deep-merges the facet map one\n * holder at a time and replaces an array whole: exactly the two behaviors\n * the rule above needs.\n */\n\nimport {\n CONTACT_ALTERNATE_EMAILS_CAP,\n CONTACT_ALTERNATE_EMAILS_FIELD,\n CONTACT_FACETS_FIELD,\n CONTACT_FORM_IDS_CAP,\n CONTACT_FORM_IDS_FIELD,\n CONTACT_INTERACTIONS_CAP,\n type ContactFacet,\n type ContactInteraction,\n contactDisplayName,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from './contacts'\nimport { CONTACT_COMPANY_IDS_FIELD, CONTACT_LIFECYCLE_STAGE_LABELS } from './crm'\nimport { MARKETING_CONSENT_BY_HOST_FIELD } from './marketing-consent'\nimport { nameSearchFields } from './name-search'\n\n/** The tags cap every writer applies, so a union cannot exceed it. */\nconst TAGS_CAP = 20\n/** The notes cap the record page applies to its About box. */\nconst NOTES_CAP = 5000\n\n/** Facet figures that add up: two records of one buyer are one buyer. */\nconst SUM_FIELDS = [\n 'ltvCents',\n 'ordersCount',\n 'refundedCents',\n 'refundedOrdersCount',\n] as const\n/** Facet instants where the later one is the truth. */\nconst LATEST_FIELDS = [\n 'lastPurchaseAtMs',\n 'lastRefundAtMs',\n 'lastEmailEngagementAtMs',\n] as const\n/** Facet instants where the earlier one is the truth. */\nconst EARLIEST_FIELDS = ['firstPurchaseAtMs'] as const\n\nexport interface ContactMergePlan {\n /**\n * What the survivor becomes, as a merge-`set` payload: every derived\n * field with its full value, and only the groups the merged record held\n * under `facets`. Plain values throughout — the writer adds its own\n * `updatedAt`.\n */\n survivor: Record<string, unknown>\n /** Every address the survivor answers to afterwards, primary first. */\n emails: string[]\n /** The merged record's addresses — what its leads and index entries are keyed by. */\n mergedEmails: string[]\n /**\n * The companies that lose a contact: those BOTH records were filed under,\n * which counted two people and now count one. A company only one record\n * named keeps its count — the survivor still names it.\n */\n companyCounts: Array<{ companyId: string; delta: -1 }>\n /** The union of both records' scope tokens — who may read the survivor. */\n visibleTo: string[]\n}\n\ntype Doc = Record<string, unknown>\n\n/** Whether a value counts as \"nothing here\" for the fill rule. */\nfunction isEmpty(value: unknown): boolean {\n if (value === undefined || value === null) return true\n if (typeof value === 'string') return value.trim() === ''\n if (Array.isArray(value)) return value.length === 0\n if (typeof value === 'object') return Object.keys(value as object).length === 0\n return false\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value : ''\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((item): item is string => typeof item === 'string' && item !== '')\n : []\n}\n\nfunction number(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/** The union of two string lists, first list's order first, deduplicated. */\nfunction union(a: unknown, b: unknown, cap = Number.POSITIVE_INFINITY): string[] {\n return [...new Set([...strings(a), ...strings(b)])].slice(0, cap)\n}\n\n/** Survivor's value where it has one, the merged record's otherwise. */\nfunction fill<T>(survivor: T, merged: T): T {\n return isEmpty(survivor) ? merged : survivor\n}\n\n/** Notes appended, survivor first, one blank line between, bounded. */\nfunction joinNotes(survivor: unknown, merged: unknown): string {\n const a = text(survivor).trim()\n const b = text(merged).trim()\n if (!b || a === b) return a\n if (!a) return b\n return `${a}\\n\\n${b}`.slice(0, NOTES_CAP)\n}\n\n/** What makes two timeline entries the same event. */\nfunction interactionKey(entry: ContactInteraction): string {\n return [entry.type, entry.refId ?? '', entry.atMs, entry.hostId ?? ''].join('|')\n}\n\n/**\n * Both timelines as one: deduplicated by event, newest first, and capped\n * the way every capture caps it — so a merge of two full timelines keeps\n * the fifty most recent visits rather than growing the document past what\n * a capture may write.\n */\nexport function mergeInteractions(\n survivor: readonly ContactInteraction[] | undefined,\n merged: readonly ContactInteraction[] | undefined,\n): ContactInteraction[] {\n const seen = new Set<string>()\n const out: ContactInteraction[] = []\n for (const entry of [...(survivor ?? []), ...(merged ?? [])]) {\n if (!entry || typeof entry !== 'object') continue\n const key = interactionKey(entry)\n if (seen.has(key)) continue\n seen.add(key)\n out.push(entry)\n }\n return out\n .sort((a, b) => (number(b.atMs) ?? 0) - (number(a.atMs) ?? 0))\n .slice(0, CONTACT_INTERACTIONS_CAP)\n}\n\n/** The custom values per key: the survivor's where set, the merged record's otherwise. */\nfunction mergeCustom(survivor: unknown, merged: unknown): Record<string, unknown> {\n const a = (survivor && typeof survivor === 'object' ? survivor : {}) as Doc\n const b = (merged && typeof merged === 'object' ? merged : {}) as Doc\n const out: Doc = { ...b }\n for (const [key, value] of Object.entries(a)) {\n if (!isEmpty(value) || !(key in b)) out[key] = value\n }\n return out\n}\n\n/**\n * One holder's facet after the merge.\n *\n * `survivor` is that holder's facet on the surviving record — possibly\n * absent, when only the merged record was held by this group — and `merged`\n * the same holder's facet on the record being folded in.\n */\nexport function mergeContactFacet(\n survivor: Doc | undefined,\n merged: Doc | undefined,\n): Doc {\n if (!merged) return { ...(survivor ?? {}) }\n if (!survivor) return { ...merged }\n const out: Doc = { ...merged }\n // Every scalar the survivor holds stands; every one it lacks fills.\n for (const [key, value] of Object.entries(survivor)) {\n out[key] = fill(value, merged[key])\n }\n const a = survivor as unknown as ContactFacet\n const b = merged as unknown as ContactFacet\n out['sources'] = { ...(b.sources ?? {}), ...(a.sources ?? {}) }\n out['interactions'] = mergeInteractions(a.interactions, b.interactions)\n const tags = union(a.tags, b.tags, TAGS_CAP)\n if (tags.length) out['tags'] = tags\n const campaignIds = union(a.campaignIds, b.campaignIds)\n if (campaignIds.length) out['campaignIds'] = campaignIds\n const notes = joinNotes(a.notes, b.notes)\n if (notes) out['notes'] = notes\n if (!isEmpty(a.custom) || !isEmpty(b.custom)) {\n out['custom'] = mergeCustom(a.custom, b.custom)\n }\n for (const field of SUM_FIELDS) {\n const left = number(a[field])\n const right = number(b[field])\n if (left !== null || right !== null) out[field] = (left ?? 0) + (right ?? 0)\n }\n for (const field of LATEST_FIELDS) {\n const left = number(a[field])\n const right = number(b[field])\n if (left !== null || right !== null) {\n out[field] = Math.max(left ?? 0, right ?? 0)\n }\n }\n for (const field of EARLIEST_FIELDS) {\n const left = number(a[field])\n const right = number(b[field])\n if (left !== null && right !== null) out[field] = Math.min(left, right)\n else if (left !== null || right !== null) out[field] = left ?? right\n }\n return out\n}\n\nfunction facetsOf(doc: Doc): Record<string, Doc> {\n const facets = doc[CONTACT_FACETS_FIELD]\n return facets && typeof facets === 'object' && !Array.isArray(facets)\n ? (facets as Record<string, Doc>)\n : {}\n}\n\nfunction mapOf(value: unknown): Doc {\n return value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Doc)\n : {}\n}\n\n/**\n * The plan for folding `merged` into `survivor`.\n *\n * Both are the stored documents as read. The survivor's `email` is the\n * identity that stands; the merged record's becomes an alternate.\n */\nexport function planContactMerge(survivor: Doc, merged: Doc): ContactMergePlan {\n const survivorEmail = normalizeContactEmail(survivor['email']) ?? ''\n const mergedEmails = contactEmails(merged)\n const alternates = [\n ...new Set([\n ...strings(survivor[CONTACT_ALTERNATE_EMAILS_FIELD]),\n ...mergedEmails,\n ]),\n ]\n .filter((email) => email !== survivorEmail)\n .slice(0, CONTACT_ALTERNATE_EMAILS_CAP)\n\n const out: Doc = {\n [CONTACT_ALTERNATE_EMAILS_FIELD]: alternates,\n }\n // The canonical name, with its search keys, only when the survivor had none.\n const survivorName = text(survivor['name']).trim()\n const mergedName = text(merged['name']).trim()\n if (!survivorName && mergedName) Object.assign(out, nameSearchFields(mergedName))\n // The search echoes, on the same fill rule.\n for (const key of ['phone', 'companyName'] as const) {\n if (isEmpty(survivor[key]) && !isEmpty(merged[key])) out[key] = merged[key]\n }\n // The shared lists: who may read the row, who captured it, which forms and\n // companies it is filed under.\n const visibleTo = union(survivor['visibleTo'], merged['visibleTo'])\n out['visibleTo'] = visibleTo\n const capturedBy = union(survivor['capturedByHostIds'], merged['capturedByHostIds'])\n if (capturedBy.length) out['capturedByHostIds'] = capturedBy\n const formIds = union(\n survivor[CONTACT_FORM_IDS_FIELD],\n merged[CONTACT_FORM_IDS_FIELD],\n CONTACT_FORM_IDS_CAP,\n )\n if (formIds.length) out[CONTACT_FORM_IDS_FIELD] = formIds\n const survivorCompanies = strings(survivor[CONTACT_COMPANY_IDS_FIELD])\n const mergedCompanies = strings(merged[CONTACT_COMPANY_IDS_FIELD])\n const companyIds = [...new Set([...survivorCompanies, ...mergedCompanies])]\n if (companyIds.length) out[CONTACT_COMPANY_IDS_FIELD] = companyIds\n const companyCounts = survivorCompanies\n .filter((id) => mergedCompanies.includes(id))\n .map((companyId) => ({ companyId, delta: -1 as const }))\n // The organization-level fields the REST API reads and writes.\n const tags = union(survivor['tags'], merged['tags'])\n if (tags.length) out['tags'] = tags\n const notes = joinNotes(survivor['notes'], merged['notes'])\n if (notes) out['notes'] = notes\n if (!isEmpty(survivor['custom']) || !isEmpty(merged['custom'])) {\n out['custom'] = mergeCustom(survivor['custom'], merged['custom'])\n }\n // Consent: a grant fills where the survivor has none for that site; a\n // refusal on either record stands.\n const consentByHost = {\n ...mapOf(merged[MARKETING_CONSENT_BY_HOST_FIELD]),\n ...mapOf(survivor[MARKETING_CONSENT_BY_HOST_FIELD]),\n }\n if (Object.keys(consentByHost).length) {\n out[MARKETING_CONSENT_BY_HOST_FIELD] = consentByHost\n }\n if (survivor['marketingConsent'] === false || merged['marketingConsent'] === false) {\n out['marketingConsent'] = false\n } else if (survivor['marketingConsent'] === true || merged['marketingConsent'] === true) {\n out['marketingConsent'] = true\n }\n // Each holder's facet — only the groups the merged record held need a write.\n const survivorFacets = facetsOf(survivor)\n const mergedFacets = facetsOf(merged)\n const facets: Record<string, Doc> = {}\n for (const [groupId, facet] of Object.entries(mergedFacets)) {\n facets[groupId] = mergeContactFacet(survivorFacets[groupId], facet)\n }\n if (Object.keys(facets).length) out[CONTACT_FACETS_FIELD] = facets\n\n return {\n survivor: out,\n emails: [survivorEmail, ...alternates].filter(Boolean),\n mergedEmails,\n companyCounts,\n visibleTo,\n }\n}\n\n/** Where a previewed value comes from. */\nexport type ContactMergeSource = 'survivor' | 'merged' | 'both' | 'none'\n\n/** One line of the side-by-side the dialog shows. */\nexport interface ContactMergePreviewRow {\n key: string\n label: string\n /** The surviving record's value, as text. */\n survivor: string\n /** The merged record's value, as text. */\n merged: string\n /** What the survivor carries afterwards. */\n result: string\n from: ContactMergeSource\n}\n\nexport interface ContactMergePreviewOptions {\n /** The owner's display name for a uid; the uid itself when absent. */\n memberName?: (uid: string) => string\n}\n\nfunction addressLine(value: unknown): string {\n const address = mapOf(value)\n return ['line1', 'line2', 'city', 'state', 'postalCode', 'country']\n .map((key) => text(address[key]).trim())\n .filter(Boolean)\n .join(', ')\n}\n\nfunction sourceOf(survivor: string, merged: string, result: string): ContactMergeSource {\n if (!result) return 'none'\n if (survivor && merged && survivor !== merged && result !== survivor && result !== merged) {\n return 'both'\n }\n if (result === survivor && survivor === merged) return 'both'\n return result === survivor ? 'survivor' : 'merged'\n}\n\n/**\n * The merge as the viewing group would see it, field by field.\n *\n * Reads through ONE group's facet, the way the record page does, so a\n * holder previewing a merge sees its own values and never another holder's.\n * Every row carries the survivor's value, the merged record's, and the\n * result the plan would write, so the dialog is a rendering and not a second\n * opinion.\n */\nexport function contactMergePreview(\n survivor: Doc,\n merged: Doc,\n groupId: string,\n options: ContactMergePreviewOptions = {},\n): ContactMergePreviewRow[] {\n const plan = planContactMerge(survivor, merged)\n const after: Doc = {\n ...survivor,\n ...plan.survivor,\n [CONTACT_FACETS_FIELD]: {\n ...facetsOf(survivor),\n ...mapOf(plan.survivor[CONTACT_FACETS_FIELD]),\n },\n }\n const a = readContactFacet(survivor, groupId)\n const b = readContactFacet(merged, groupId)\n const c = readContactFacet(after, groupId)\n const memberName = options.memberName ?? ((uid: string) => uid)\n const stage = (value: unknown) =>\n typeof value === 'string' && value in CONTACT_LIFECYCLE_STAGE_LABELS\n ? CONTACT_LIFECYCLE_STAGE_LABELS[value as keyof typeof CONTACT_LIFECYCLE_STAGE_LABELS]\n : ''\n const owner = (value: unknown) => (text(value) ? memberName(text(value)) : '')\n const list = (value: unknown) => strings(value).join(', ')\n const money = (facet: ContactFacet) =>\n facet.ordersCount\n ? `${facet.ordersCount} · $${((facet.ltvCents ?? 0) / 100).toFixed(2)}`\n : ''\n const row = (\n key: string,\n label: string,\n left: string,\n right: string,\n result: string,\n ): ContactMergePreviewRow => ({\n key,\n label,\n survivor: left,\n merged: right,\n result,\n from: sourceOf(left, right, result),\n })\n const rows: ContactMergePreviewRow[] = [\n row(\n 'name',\n 'Name',\n contactDisplayName(survivor, groupId),\n contactDisplayName(merged, groupId),\n contactDisplayName(after, groupId),\n ),\n row(\n 'email',\n 'Email',\n contactEmails(survivor).join(', '),\n contactEmails(merged).join(', '),\n plan.emails.join(', '),\n ),\n row('phone', 'Phone', text(a.phone), text(b.phone), text(c.phone)),\n row('jobTitle', 'Job title', text(a.jobTitle), text(b.jobTitle), text(c.jobTitle)),\n row(\n 'company',\n 'Company',\n text(a.companyName),\n text(b.companyName),\n text(c.companyName),\n ),\n row(\n 'lifecycleStage',\n 'Stage',\n stage(a.lifecycleStage),\n stage(b.lifecycleStage),\n stage(c.lifecycleStage),\n ),\n row('owner', 'Owner', owner(a.ownerUid), owner(b.ownerUid), owner(c.ownerUid)),\n row('tags', 'Tags', list(a.tags), list(b.tags), list(c.tags)),\n row('notes', 'Notes', text(a.notes), text(b.notes), text(c.notes)),\n row(\n 'address',\n 'Address',\n addressLine(a.address),\n addressLine(b.address),\n addressLine(c.address),\n ),\n row('orders', 'Orders', money(a), money(b), money(c)),\n row(\n 'timeline',\n 'Timeline entries',\n a.interactions.length ? String(a.interactions.length) : '',\n b.interactions.length ? String(b.interactions.length) : '',\n c.interactions.length ? String(c.interactions.length) : '',\n ),\n ]\n const customKeys = [\n ...new Set([...Object.keys(mapOf(a.custom)), ...Object.keys(mapOf(b.custom))]),\n ].sort()\n for (const key of customKeys) {\n const show = (facet: ContactFacet) => {\n const value = mapOf(facet.custom)[key]\n return value === null || value === undefined ? '' : String(value)\n }\n rows.push(row(`custom.${key}`, key, show(a), show(b), show(c)))\n }\n return rows\n}\n"],"names":["CONTACT_ALTERNATE_EMAILS_CAP","CONTACT_ALTERNATE_EMAILS_FIELD","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","CONTACT_INTERACTIONS_CAP","contactDisplayName","contactEmails","normalizeContactEmail","readContactFacet","CONTACT_COMPANY_IDS_FIELD","CONTACT_LIFECYCLE_STAGE_LABELS","MARKETING_CONSENT_BY_HOST_FIELD","nameSearchFields","TAGS_CAP","NOTES_CAP","SUM_FIELDS","LATEST_FIELDS","EARLIEST_FIELDS","isEmpty","value","undefined","trim","Array","isArray","length","Object","keys","text","strings","filter","item","number","Number","isFinite","union","a","b","cap","POSITIVE_INFINITY","Set","slice","fill","survivor","merged","joinNotes","interactionKey","entry","type","refId","atMs","hostId","join","mergeInteractions","seen","out","key","has","add","push","sort","mergeCustom","entries","mergeContactFacet","sources","interactions","tags","campaignIds","notes","custom","field","left","right","Math","max","min","facetsOf","doc","facets","mapOf","planContactMerge","survivorEmail","mergedEmails","alternates","email","survivorName","mergedName","assign","visibleTo","capturedBy","formIds","survivorCompanies","mergedCompanies","companyIds","companyCounts","id","includes","map","companyId","delta","consentByHost","survivorFacets","mergedFacets","groupId","facet","emails","Boolean","addressLine","address","sourceOf","result","contactMergePreview","options","plan","after","c","memberName","uid","stage","owner","list","money","ordersCount","ltvCents","toFixed","row","label","from","rows","phone","jobTitle","companyName","lifecycleStage","ownerUid","String","customKeys","show"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GAED,SACEA,4BAA4B,EAC5BC,8BAA8B,EAC9BC,oBAAoB,EACpBC,oBAAoB,EACpBC,sBAAsB,EACtBC,wBAAwB,EAGxBC,kBAAkB,EAClBC,aAAa,EACbC,qBAAqB,EACrBC,gBAAgB,QACX,gBAAY;AACnB,SAASC,yBAAyB,EAAEC,8BAA8B,QAAQ,WAAO;AACjF,SAASC,+BAA+B,QAAQ,yBAAqB;AACrE,SAASC,gBAAgB,QAAQ,mBAAe;AAEhD,oEAAoE,GACpE,MAAMC,WAAW;AACjB,4DAA4D,GAC5D,MAAMC,YAAY;AAElB,uEAAuE,GACvE,MAAMC,aAAa;IACjB;IACA;IACA;IACA;CACD;AACD,qDAAqD,GACrD,MAAMC,gBAAgB;IACpB;IACA;IACA;CACD;AACD,uDAAuD,GACvD,MAAMC,kBAAkB;IAAC;CAAoB;AA0B7C,gEAAgE,GAChE,SAASC,QAAQC,KAAc;IAC7B,IAAIA,UAAUC,aAAaD,UAAU,MAAM,OAAO;IAClD,IAAI,OAAOA,UAAU,UAAU,OAAOA,MAAME,IAAI,OAAO;IACvD,IAAIC,MAAMC,OAAO,CAACJ,QAAQ,OAAOA,MAAMK,MAAM,KAAK;IAClD,IAAI,OAAOL,UAAU,UAAU,OAAOM,OAAOC,IAAI,CAACP,OAAiBK,MAAM,KAAK;IAC9E,OAAO;AACT;AAEA,SAASG,KAAKR,KAAc;IAC1B,OAAO,OAAOA,UAAU,WAAWA,QAAQ;AAC7C;AAEA,SAASS,QAAQT,KAAc;IAC7B,OAAOG,MAAMC,OAAO,CAACJ,SACjBA,MAAMU,MAAM,CAAC,CAACC,OAAyB,OAAOA,SAAS,YAAYA,SAAS,MAC5E,EAAE;AACR;AAEA,SAASC,OAAOZ,KAAc;IAC5B,OAAO,OAAOA,UAAU,YAAYa,OAAOC,QAAQ,CAACd,SAASA,QAAQ;AACvE;AAEA,2EAA2E,GAC3E,SAASe,MAAMC,CAAU,EAAEC,CAAU,EAAEC,MAAML,OAAOM,iBAAiB;IACnE,OAAO;WAAI,IAAIC,IAAI;eAAIX,QAAQO;eAAOP,QAAQQ;SAAG;KAAE,CAACI,KAAK,CAAC,GAAGH;AAC/D;AAEA,sEAAsE,GACtE,SAASI,KAAQC,QAAW,EAAEC,MAAS;IACrC,OAAOzB,QAAQwB,YAAYC,SAASD;AACtC;AAEA,qEAAqE,GACrE,SAASE,UAAUF,QAAiB,EAAEC,MAAe;IACnD,MAAMR,IAAIR,KAAKe,UAAUrB,IAAI;IAC7B,MAAMe,IAAIT,KAAKgB,QAAQtB,IAAI;IAC3B,IAAI,CAACe,KAAKD,MAAMC,GAAG,OAAOD;IAC1B,IAAI,CAACA,GAAG,OAAOC;IACf,OAAO,GAAGD,EAAE,IAAI,EAAEC,GAAG,CAACI,KAAK,CAAC,GAAG1B;AACjC;AAEA,oDAAoD,GACpD,SAAS+B,eAAeC,KAAyB;QAC3BA,cAA+BA;IAAnD,OAAO;QAACA,MAAMC,IAAI;SAAED,eAAAA,MAAME,KAAK,YAAXF,eAAe;QAAIA,MAAMG,IAAI;SAAEH,gBAAAA,MAAMI,MAAM,YAAZJ,gBAAgB;KAAG,CAACK,IAAI,CAAC;AAC9E;AAEA;;;;;CAKC,GACD,OAAO,SAASC,kBACdV,QAAmD,EACnDC,MAAiD;IAEjD,MAAMU,OAAO,IAAId;IACjB,MAAMe,MAA4B,EAAE;IACpC,KAAK,MAAMR,SAAS;WAAKJ,mBAAAA,WAAY,EAAE;WAAOC,iBAAAA,SAAU,EAAE;KAAE,CAAE;QAC5D,IAAI,CAACG,SAAS,OAAOA,UAAU,UAAU;QACzC,MAAMS,MAAMV,eAAeC;QAC3B,IAAIO,KAAKG,GAAG,CAACD,MAAM;QACnBF,KAAKI,GAAG,CAACF;QACTD,IAAII,IAAI,CAACZ;IACX;IACA,OAAOQ,IACJK,IAAI,CAAC,CAACxB,GAAGC;YAAOL,SAAwBA;eAAzB,EAACA,UAAAA,OAAOK,EAAEa,IAAI,aAAblB,UAAkB,OAAMA,WAAAA,OAAOI,EAAEc,IAAI,aAAblB,WAAkB;OAC1DS,KAAK,CAAC,GAAGpC;AACd;AAEA,wFAAwF,GACxF,SAASwD,YAAYlB,QAAiB,EAAEC,MAAe;IACrD,MAAMR,IAAKO,YAAY,OAAOA,aAAa,WAAWA,WAAW,CAAC;IAClE,MAAMN,IAAKO,UAAU,OAAOA,WAAW,WAAWA,SAAS,CAAC;IAC5D,MAAMW,MAAW,aAAKlB;IACtB,KAAK,MAAM,CAACmB,KAAKpC,MAAM,IAAIM,OAAOoC,OAAO,CAAC1B,GAAI;QAC5C,IAAI,CAACjB,QAAQC,UAAU,CAAEoC,CAAAA,OAAOnB,CAAAA,GAAIkB,GAAG,CAACC,IAAI,GAAGpC;IACjD;IACA,OAAOmC;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASQ,kBACdpB,QAAyB,EACzBC,MAAuB;QAWAP,YAAsBD;IAT7C,IAAI,CAACQ,QAAQ,OAAO,aAAMD,mBAAAA,WAAY,CAAC;IACvC,IAAI,CAACA,UAAU,OAAO,aAAKC;IAC3B,MAAMW,MAAW,aAAKX;IACtB,oEAAoE;IACpE,KAAK,MAAM,CAACY,KAAKpC,MAAM,IAAIM,OAAOoC,OAAO,CAACnB,UAAW;QACnDY,GAAG,CAACC,IAAI,GAAGd,KAAKtB,OAAOwB,MAAM,CAACY,IAAI;IACpC;IACA,MAAMpB,IAAIO;IACV,MAAMN,IAAIO;IACVW,GAAG,CAAC,UAAU,GAAG,cAAMlB,aAAAA,EAAE2B,OAAO,YAAT3B,aAAa,CAAC,IAAQD,aAAAA,EAAE4B,OAAO,YAAT5B,aAAa,CAAC;IAC3DmB,GAAG,CAAC,eAAe,GAAGF,kBAAkBjB,EAAE6B,YAAY,EAAE5B,EAAE4B,YAAY;IACtE,MAAMC,OAAO/B,MAAMC,EAAE8B,IAAI,EAAE7B,EAAE6B,IAAI,EAAEpD;IACnC,IAAIoD,KAAKzC,MAAM,EAAE8B,GAAG,CAAC,OAAO,GAAGW;IAC/B,MAAMC,cAAchC,MAAMC,EAAE+B,WAAW,EAAE9B,EAAE8B,WAAW;IACtD,IAAIA,YAAY1C,MAAM,EAAE8B,GAAG,CAAC,cAAc,GAAGY;IAC7C,MAAMC,QAAQvB,UAAUT,EAAEgC,KAAK,EAAE/B,EAAE+B,KAAK;IACxC,IAAIA,OAAOb,GAAG,CAAC,QAAQ,GAAGa;IAC1B,IAAI,CAACjD,QAAQiB,EAAEiC,MAAM,KAAK,CAAClD,QAAQkB,EAAEgC,MAAM,GAAG;QAC5Cd,GAAG,CAAC,SAAS,GAAGM,YAAYzB,EAAEiC,MAAM,EAAEhC,EAAEgC,MAAM;IAChD;IACA,KAAK,MAAMC,SAAStD,WAAY;QAC9B,MAAMuD,OAAOvC,OAAOI,CAAC,CAACkC,MAAM;QAC5B,MAAME,QAAQxC,OAAOK,CAAC,CAACiC,MAAM;QAC7B,IAAIC,SAAS,QAAQC,UAAU,MAAMjB,GAAG,CAACe,MAAM,GAAG,CAACC,eAAAA,OAAQ,MAAMC,gBAAAA,QAAS;IAC5E;IACA,KAAK,MAAMF,SAASrD,cAAe;QACjC,MAAMsD,OAAOvC,OAAOI,CAAC,CAACkC,MAAM;QAC5B,MAAME,QAAQxC,OAAOK,CAAC,CAACiC,MAAM;QAC7B,IAAIC,SAAS,QAAQC,UAAU,MAAM;YACnCjB,GAAG,CAACe,MAAM,GAAGG,KAAKC,GAAG,CAACH,eAAAA,OAAQ,GAAGC,gBAAAA,QAAS;QAC5C;IACF;IACA,KAAK,MAAMF,SAASpD,gBAAiB;QACnC,MAAMqD,OAAOvC,OAAOI,CAAC,CAACkC,MAAM;QAC5B,MAAME,QAAQxC,OAAOK,CAAC,CAACiC,MAAM;QAC7B,IAAIC,SAAS,QAAQC,UAAU,MAAMjB,GAAG,CAACe,MAAM,GAAGG,KAAKE,GAAG,CAACJ,MAAMC;aAC5D,IAAID,SAAS,QAAQC,UAAU,MAAMjB,GAAG,CAACe,MAAM,GAAGC,eAAAA,OAAQC;IACjE;IACA,OAAOjB;AACT;AAEA,SAASqB,SAASC,GAAQ;IACxB,MAAMC,SAASD,GAAG,CAAC3E,qBAAqB;IACxC,OAAO4E,UAAU,OAAOA,WAAW,YAAY,CAACvD,MAAMC,OAAO,CAACsD,UACzDA,SACD,CAAC;AACP;AAEA,SAASC,MAAM3D,KAAc;IAC3B,OAAOA,SAAS,OAAOA,UAAU,YAAY,CAACG,MAAMC,OAAO,CAACJ,SACvDA,QACD,CAAC;AACP;AAEA;;;;;CAKC,GACD,OAAO,SAAS4D,iBAAiBrC,QAAa,EAAEC,MAAW;QACnCpC;IAAtB,MAAMyE,iBAAgBzE,yBAAAA,sBAAsBmC,QAAQ,CAAC,QAAQ,aAAvCnC,yBAA4C;IAClE,MAAM0E,eAAe3E,cAAcqC;IACnC,MAAMuC,aAAa;WACd,IAAI3C,IAAI;eACNX,QAAQc,QAAQ,CAAC1C,+BAA+B;eAChDiF;SACJ;KACF,CACEpD,MAAM,CAAC,CAACsD,QAAUA,UAAUH,eAC5BxC,KAAK,CAAC,GAAGzC;IAEZ,MAAMuD,MAAW;QACf,CAACtD,+BAA+B,EAAEkF;IACpC;IACA,6EAA6E;IAC7E,MAAME,eAAezD,KAAKe,QAAQ,CAAC,OAAO,EAAErB,IAAI;IAChD,MAAMgE,aAAa1D,KAAKgB,MAAM,CAAC,OAAO,EAAEtB,IAAI;IAC5C,IAAI,CAAC+D,gBAAgBC,YAAY5D,OAAO6D,MAAM,CAAChC,KAAK1C,iBAAiByE;IACrE,4CAA4C;IAC5C,KAAK,MAAM9B,OAAO;QAAC;QAAS;KAAc,CAAW;QACnD,IAAIrC,QAAQwB,QAAQ,CAACa,IAAI,KAAK,CAACrC,QAAQyB,MAAM,CAACY,IAAI,GAAGD,GAAG,CAACC,IAAI,GAAGZ,MAAM,CAACY,IAAI;IAC7E;IACA,2EAA2E;IAC3E,+BAA+B;IAC/B,MAAMgC,YAAYrD,MAAMQ,QAAQ,CAAC,YAAY,EAAEC,MAAM,CAAC,YAAY;IAClEW,GAAG,CAAC,YAAY,GAAGiC;IACnB,MAAMC,aAAatD,MAAMQ,QAAQ,CAAC,oBAAoB,EAAEC,MAAM,CAAC,oBAAoB;IACnF,IAAI6C,WAAWhE,MAAM,EAAE8B,GAAG,CAAC,oBAAoB,GAAGkC;IAClD,MAAMC,UAAUvD,MACdQ,QAAQ,CAACvC,uBAAuB,EAChCwC,MAAM,CAACxC,uBAAuB,EAC9BD;IAEF,IAAIuF,QAAQjE,MAAM,EAAE8B,GAAG,CAACnD,uBAAuB,GAAGsF;IAClD,MAAMC,oBAAoB9D,QAAQc,QAAQ,CAACjC,0BAA0B;IACrE,MAAMkF,kBAAkB/D,QAAQe,MAAM,CAAClC,0BAA0B;IACjE,MAAMmF,aAAa;WAAI,IAAIrD,IAAI;eAAImD;eAAsBC;SAAgB;KAAE;IAC3E,IAAIC,WAAWpE,MAAM,EAAE8B,GAAG,CAAC7C,0BAA0B,GAAGmF;IACxD,MAAMC,gBAAgBH,kBACnB7D,MAAM,CAAC,CAACiE,KAAOH,gBAAgBI,QAAQ,CAACD,KACxCE,GAAG,CAAC,CAACC,YAAe,CAAA;YAAEA;YAAWC,OAAO,CAAC;QAAW,CAAA;IACvD,+DAA+D;IAC/D,MAAMjC,OAAO/B,MAAMQ,QAAQ,CAAC,OAAO,EAAEC,MAAM,CAAC,OAAO;IACnD,IAAIsB,KAAKzC,MAAM,EAAE8B,GAAG,CAAC,OAAO,GAAGW;IAC/B,MAAME,QAAQvB,UAAUF,QAAQ,CAAC,QAAQ,EAAEC,MAAM,CAAC,QAAQ;IAC1D,IAAIwB,OAAOb,GAAG,CAAC,QAAQ,GAAGa;IAC1B,IAAI,CAACjD,QAAQwB,QAAQ,CAAC,SAAS,KAAK,CAACxB,QAAQyB,MAAM,CAAC,SAAS,GAAG;QAC9DW,GAAG,CAAC,SAAS,GAAGM,YAAYlB,QAAQ,CAAC,SAAS,EAAEC,MAAM,CAAC,SAAS;IAClE;IACA,sEAAsE;IACtE,mCAAmC;IACnC,MAAMwD,gBAAgB,aACjBrB,MAAMnC,MAAM,CAAChC,gCAAgC,GAC7CmE,MAAMpC,QAAQ,CAAC/B,gCAAgC;IAEpD,IAAIc,OAAOC,IAAI,CAACyE,eAAe3E,MAAM,EAAE;QACrC8B,GAAG,CAAC3C,gCAAgC,GAAGwF;IACzC;IACA,IAAIzD,QAAQ,CAAC,mBAAmB,KAAK,SAASC,MAAM,CAAC,mBAAmB,KAAK,OAAO;QAClFW,GAAG,CAAC,mBAAmB,GAAG;IAC5B,OAAO,IAAIZ,QAAQ,CAAC,mBAAmB,KAAK,QAAQC,MAAM,CAAC,mBAAmB,KAAK,MAAM;QACvFW,GAAG,CAAC,mBAAmB,GAAG;IAC5B;IACA,6EAA6E;IAC7E,MAAM8C,iBAAiBzB,SAASjC;IAChC,MAAM2D,eAAe1B,SAAShC;IAC9B,MAAMkC,SAA8B,CAAC;IACrC,KAAK,MAAM,CAACyB,SAASC,MAAM,IAAI9E,OAAOoC,OAAO,CAACwC,cAAe;QAC3DxB,MAAM,CAACyB,QAAQ,GAAGxC,kBAAkBsC,cAAc,CAACE,QAAQ,EAAEC;IAC/D;IACA,IAAI9E,OAAOC,IAAI,CAACmD,QAAQrD,MAAM,EAAE8B,GAAG,CAACrD,qBAAqB,GAAG4E;IAE5D,OAAO;QACLnC,UAAUY;QACVkD,QAAQ;YAACxB;eAAkBE;SAAW,CAACrD,MAAM,CAAC4E;QAC9CxB;QACAY;QACAN;IACF;AACF;AAuBA,SAASmB,YAAYvF,KAAc;IACjC,MAAMwF,UAAU7B,MAAM3D;IACtB,OAAO;QAAC;QAAS;QAAS;QAAQ;QAAS;QAAc;KAAU,CAChE6E,GAAG,CAAC,CAACzC,MAAQ5B,KAAKgF,OAAO,CAACpD,IAAI,EAAElC,IAAI,IACpCQ,MAAM,CAAC4E,SACPtD,IAAI,CAAC;AACV;AAEA,SAASyD,SAASlE,QAAgB,EAAEC,MAAc,EAAEkE,MAAc;IAChE,IAAI,CAACA,QAAQ,OAAO;IACpB,IAAInE,YAAYC,UAAUD,aAAaC,UAAUkE,WAAWnE,YAAYmE,WAAWlE,QAAQ;QACzF,OAAO;IACT;IACA,IAAIkE,WAAWnE,YAAYA,aAAaC,QAAQ,OAAO;IACvD,OAAOkE,WAAWnE,WAAW,aAAa;AAC5C;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASoE,oBACdpE,QAAa,EACbC,MAAW,EACX2D,OAAe,EACfS,UAAsC,CAAC,CAAC;QAcrBA;IAZnB,MAAMC,OAAOjC,iBAAiBrC,UAAUC;IACxC,MAAMsE,QAAa,aACdvE,UACAsE,KAAKtE,QAAQ;QAChB,CAACzC,qBAAqB,EAAE,aACnB0E,SAASjC,WACToC,MAAMkC,KAAKtE,QAAQ,CAACzC,qBAAqB;;IAGhD,MAAMkC,IAAI3B,iBAAiBkC,UAAU4D;IACrC,MAAMlE,IAAI5B,iBAAiBmC,QAAQ2D;IACnC,MAAMY,IAAI1G,iBAAiByG,OAAOX;IAClC,MAAMa,cAAaJ,sBAAAA,QAAQI,UAAU,YAAlBJ,sBAAuB,CAACK,MAAgBA;IAC3D,MAAMC,QAAQ,CAAClG,QACb,OAAOA,UAAU,YAAYA,SAAST,iCAClCA,8BAA8B,CAACS,MAAqD,GACpF;IACN,MAAMmG,QAAQ,CAACnG,QAAoBQ,KAAKR,SAASgG,WAAWxF,KAAKR,UAAU;IAC3E,MAAMoG,OAAO,CAACpG,QAAmBS,QAAQT,OAAOgC,IAAI,CAAC;IACrD,MAAMqE,QAAQ,CAACjB;YAEoBA;eADjCA,MAAMkB,WAAW,GACb,GAAGlB,MAAMkB,WAAW,CAAC,IAAI,EAAE,AAAC,CAAA,EAAClB,kBAAAA,MAAMmB,QAAQ,YAAdnB,kBAAkB,KAAK,GAAE,EAAGoB,OAAO,CAAC,IAAI,GACrE;;IACN,MAAMC,MAAM,CACVrE,KACAsE,OACAvD,MACAC,OACAsC,SAC4B,CAAA;YAC5BtD;YACAsE;YACAnF,UAAU4B;YACV3B,QAAQ4B;YACRsC;YACAiB,MAAMlB,SAAStC,MAAMC,OAAOsC;QAC9B,CAAA;IACA,MAAMkB,OAAiC;QACrCH,IACE,QACA,QACAvH,mBAAmBqC,UAAU4D,UAC7BjG,mBAAmBsC,QAAQ2D,UAC3BjG,mBAAmB4G,OAAOX;QAE5BsB,IACE,SACA,SACAtH,cAAcoC,UAAUS,IAAI,CAAC,OAC7B7C,cAAcqC,QAAQQ,IAAI,CAAC,OAC3B6D,KAAKR,MAAM,CAACrD,IAAI,CAAC;QAEnByE,IAAI,SAAS,SAASjG,KAAKQ,EAAE6F,KAAK,GAAGrG,KAAKS,EAAE4F,KAAK,GAAGrG,KAAKuF,EAAEc,KAAK;QAChEJ,IAAI,YAAY,aAAajG,KAAKQ,EAAE8F,QAAQ,GAAGtG,KAAKS,EAAE6F,QAAQ,GAAGtG,KAAKuF,EAAEe,QAAQ;QAChFL,IACE,WACA,WACAjG,KAAKQ,EAAE+F,WAAW,GAClBvG,KAAKS,EAAE8F,WAAW,GAClBvG,KAAKuF,EAAEgB,WAAW;QAEpBN,IACE,kBACA,SACAP,MAAMlF,EAAEgG,cAAc,GACtBd,MAAMjF,EAAE+F,cAAc,GACtBd,MAAMH,EAAEiB,cAAc;QAExBP,IAAI,SAAS,SAASN,MAAMnF,EAAEiG,QAAQ,GAAGd,MAAMlF,EAAEgG,QAAQ,GAAGd,MAAMJ,EAAEkB,QAAQ;QAC5ER,IAAI,QAAQ,QAAQL,KAAKpF,EAAE8B,IAAI,GAAGsD,KAAKnF,EAAE6B,IAAI,GAAGsD,KAAKL,EAAEjD,IAAI;QAC3D2D,IAAI,SAAS,SAASjG,KAAKQ,EAAEgC,KAAK,GAAGxC,KAAKS,EAAE+B,KAAK,GAAGxC,KAAKuF,EAAE/C,KAAK;QAChEyD,IACE,WACA,WACAlB,YAAYvE,EAAEwE,OAAO,GACrBD,YAAYtE,EAAEuE,OAAO,GACrBD,YAAYQ,EAAEP,OAAO;QAEvBiB,IAAI,UAAU,UAAUJ,MAAMrF,IAAIqF,MAAMpF,IAAIoF,MAAMN;QAClDU,IACE,YACA,oBACAzF,EAAE6B,YAAY,CAACxC,MAAM,GAAG6G,OAAOlG,EAAE6B,YAAY,CAACxC,MAAM,IAAI,IACxDY,EAAE4B,YAAY,CAACxC,MAAM,GAAG6G,OAAOjG,EAAE4B,YAAY,CAACxC,MAAM,IAAI,IACxD0F,EAAElD,YAAY,CAACxC,MAAM,GAAG6G,OAAOnB,EAAElD,YAAY,CAACxC,MAAM,IAAI;KAE3D;IACD,MAAM8G,aAAa;WACd,IAAI/F,IAAI;eAAId,OAAOC,IAAI,CAACoD,MAAM3C,EAAEiC,MAAM;eAAO3C,OAAOC,IAAI,CAACoD,MAAM1C,EAAEgC,MAAM;SAAG;KAC9E,CAACT,IAAI;IACN,KAAK,MAAMJ,OAAO+E,WAAY;QAC5B,MAAMC,OAAO,CAAChC;YACZ,MAAMpF,QAAQ2D,MAAMyB,MAAMnC,MAAM,CAAC,CAACb,IAAI;YACtC,OAAOpC,UAAU,QAAQA,UAAUC,YAAY,KAAKiH,OAAOlH;QAC7D;QACA4G,KAAKrE,IAAI,CAACkE,IAAI,CAAC,OAAO,EAAErE,KAAK,EAAEA,KAAKgF,KAAKpG,IAAIoG,KAAKnG,IAAImG,KAAKrB;IAC7D;IACA,OAAOa;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-merge.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 * MERGING TWO CONTACT DOCUMENTS INTO ONE (AGL-2625) — the pure half.\n *\n * Contacts are keyed by email, so exact duplicates cannot exist; one human\n * with `jane@acme.com` and `jane@gmail.com` is two documents, with tags,\n * deals, tasks and a timeline split between them. A merge folds the second\n * into the first: the SURVIVOR keeps its address as the identity, the MERGED\n * record's address becomes an alternate on it, and every field is combined\n * by one rule the dialog can show before the write happens.\n *\n * ## The rule\n *\n * - A scalar — a name, a phone, a title, a stage, an owner — is the\n * survivor's where the survivor has one, and fills from the merged record\n * where the survivor is empty. Nothing the survivor holds is overwritten.\n * - A list — tags, campaign filings, the timeline, the sites that captured\n * the person, the forms, the companies — is the UNION, deduplicated and\n * bounded where the write paths bound it.\n * - A figure that counts something — orders, lifetime value, refunds — is\n * the SUM: two records of one person's purchases are one person's\n * purchases. A \"first\" is the earlier, a \"last\" is the later.\n * - Notes are appended, survivor first: a note is a list of sentences, and\n * dropping the merged record's would be the one silent loss in a merge\n * that is otherwise lossless.\n * - A recorded REFUSAL of marketing mail on either record stands on the\n * survivor. The merge asserts the two are one person, and that person\n * said no.\n *\n * Applied per holder: each group's facet on the merged record is folded\n * into the same group's facet on the survivor, and a group only the merged\n * record had comes across whole. No holder's records reach another holder's\n * facet, which is the guarantee the facet shape exists for.\n *\n * ## Why this is a plan and not a write\n *\n * The console shows the two records side by side and says which value wins\n * before anything is committed; the server performs the write. Both read\n * this function, so the preview cannot show one outcome and the write land\n * another. The plan is plain values — no sentinels — so it can be rendered,\n * and it is shaped for a merge-`set`, which deep-merges the facet map one\n * holder at a time and replaces an array whole: exactly the two behaviors\n * the rule above needs.\n */\n\nimport {\n CONTACT_ALTERNATE_EMAILS_CAP,\n CONTACT_ALTERNATE_EMAILS_FIELD,\n CONTACT_FACETS_FIELD,\n CONTACT_FORM_IDS_CAP,\n CONTACT_FORM_IDS_FIELD,\n CONTACT_INTERACTIONS_CAP,\n type ContactFacet,\n type ContactInteraction,\n contactDisplayName,\n contactEmails,\n normalizeContactEmail,\n readContactFacet,\n} from './contacts'\nimport { CONTACT_COMPANY_IDS_FIELD, CONTACT_LIFECYCLE_STAGE_LABELS } from './crm'\nimport {\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n} from './marketing-consent'\nimport { nameSearchFields } from './name-search'\n\n/** The tags cap every writer applies, so a union cannot exceed it. */\nconst TAGS_CAP = 20\n/** The notes cap the record page applies to its About box. */\nconst NOTES_CAP = 5000\n\n/** Facet figures that add up: two records of one buyer are one buyer. */\nconst SUM_FIELDS = [\n 'ltvCents',\n 'ordersCount',\n 'refundedCents',\n 'refundedOrdersCount',\n] as const\n/** Facet instants where the later one is the truth. */\nconst LATEST_FIELDS = [\n 'lastPurchaseAtMs',\n 'lastRefundAtMs',\n 'lastEmailEngagementAtMs',\n] as const\n/** Facet instants where the earlier one is the truth. */\nconst EARLIEST_FIELDS = ['firstPurchaseAtMs'] as const\n\nexport interface ContactMergePlan {\n /**\n * What the survivor becomes, as a merge-`set` payload: every derived\n * field with its full value, and only the groups the merged record held\n * under `facets`. Plain values throughout — the writer adds its own\n * `updatedAt`.\n */\n survivor: Record<string, unknown>\n /** Every address the survivor answers to afterwards, primary first. */\n emails: string[]\n /** The merged record's addresses — what its leads and index entries are keyed by. */\n mergedEmails: string[]\n /**\n * The companies that lose a contact: those BOTH records were filed under,\n * which counted two people and now count one. A company only one record\n * named keeps its count — the survivor still names it.\n */\n companyCounts: Array<{ companyId: string; delta: -1 }>\n /** The union of both records' scope tokens — who may read the survivor. */\n visibleTo: string[]\n}\n\ntype Doc = Record<string, unknown>\n\n/** Whether a value counts as \"nothing here\" for the fill rule. */\nfunction isEmpty(value: unknown): boolean {\n if (value === undefined || value === null) return true\n if (typeof value === 'string') return value.trim() === ''\n if (Array.isArray(value)) return value.length === 0\n if (typeof value === 'object') return Object.keys(value as object).length === 0\n return false\n}\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value : ''\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((item): item is string => typeof item === 'string' && item !== '')\n : []\n}\n\nfunction number(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/** The union of two string lists, first list's order first, deduplicated. */\nfunction union(a: unknown, b: unknown, cap = Number.POSITIVE_INFINITY): string[] {\n return [...new Set([...strings(a), ...strings(b)])].slice(0, cap)\n}\n\n/** Survivor's value where it has one, the merged record's otherwise. */\nfunction fill<T>(survivor: T, merged: T): T {\n return isEmpty(survivor) ? merged : survivor\n}\n\n/** Notes appended, survivor first, one blank line between, bounded. */\nfunction joinNotes(survivor: unknown, merged: unknown): string {\n const a = text(survivor).trim()\n const b = text(merged).trim()\n if (!b || a === b) return a\n if (!a) return b\n return `${a}\\n\\n${b}`.slice(0, NOTES_CAP)\n}\n\n/** What makes two timeline entries the same event. */\nfunction interactionKey(entry: ContactInteraction): string {\n return [entry.type, entry.refId ?? '', entry.atMs, entry.hostId ?? ''].join('|')\n}\n\n/**\n * Both timelines as one: deduplicated by event, newest first, and capped\n * the way every capture caps it — so a merge of two full timelines keeps\n * the fifty most recent visits rather than growing the document past what\n * a capture may write.\n */\nexport function mergeInteractions(\n survivor: readonly ContactInteraction[] | undefined,\n merged: readonly ContactInteraction[] | undefined,\n): ContactInteraction[] {\n const seen = new Set<string>()\n const out: ContactInteraction[] = []\n for (const entry of [...(survivor ?? []), ...(merged ?? [])]) {\n if (!entry || typeof entry !== 'object') continue\n const key = interactionKey(entry)\n if (seen.has(key)) continue\n seen.add(key)\n out.push(entry)\n }\n return out\n .sort((a, b) => (number(b.atMs) ?? 0) - (number(a.atMs) ?? 0))\n .slice(0, CONTACT_INTERACTIONS_CAP)\n}\n\n/** The custom values per key: the survivor's where set, the merged record's otherwise. */\nfunction mergeCustom(survivor: unknown, merged: unknown): Record<string, unknown> {\n const a = (survivor && typeof survivor === 'object' ? survivor : {}) as Doc\n const b = (merged && typeof merged === 'object' ? merged : {}) as Doc\n const out: Doc = { ...b }\n for (const [key, value] of Object.entries(a)) {\n if (!isEmpty(value) || !(key in b)) out[key] = value\n }\n return out\n}\n\n/**\n * One holder's facet after the merge.\n *\n * `survivor` is that holder's facet on the surviving record — possibly\n * absent, when only the merged record was held by this group — and `merged`\n * the same holder's facet on the record being folded in.\n */\nexport function mergeContactFacet(\n survivor: Doc | undefined,\n merged: Doc | undefined,\n): Doc {\n if (!merged) return { ...(survivor ?? {}) }\n if (!survivor) return { ...merged }\n const out: Doc = { ...merged }\n // Every scalar the survivor holds stands; every one it lacks fills.\n for (const [key, value] of Object.entries(survivor)) {\n out[key] = fill(value, merged[key])\n }\n const a = survivor as unknown as ContactFacet\n const b = merged as unknown as ContactFacet\n out['sources'] = { ...(b.sources ?? {}), ...(a.sources ?? {}) }\n out['interactions'] = mergeInteractions(a.interactions, b.interactions)\n const tags = union(a.tags, b.tags, TAGS_CAP)\n if (tags.length) out['tags'] = tags\n const campaignIds = union(a.campaignIds, b.campaignIds)\n if (campaignIds.length) out['campaignIds'] = campaignIds\n const notes = joinNotes(a.notes, b.notes)\n if (notes) out['notes'] = notes\n if (!isEmpty(a.custom) || !isEmpty(b.custom)) {\n out['custom'] = mergeCustom(a.custom, b.custom)\n }\n for (const field of SUM_FIELDS) {\n const left = number(a[field])\n const right = number(b[field])\n if (left !== null || right !== null) out[field] = (left ?? 0) + (right ?? 0)\n }\n for (const field of LATEST_FIELDS) {\n const left = number(a[field])\n const right = number(b[field])\n if (left !== null || right !== null) {\n out[field] = Math.max(left ?? 0, right ?? 0)\n }\n }\n for (const field of EARLIEST_FIELDS) {\n const left = number(a[field])\n const right = number(b[field])\n if (left !== null && right !== null) out[field] = Math.min(left, right)\n else if (left !== null || right !== null) out[field] = left ?? right\n }\n return out\n}\n\nfunction facetsOf(doc: Doc): Record<string, Doc> {\n const facets = doc[CONTACT_FACETS_FIELD]\n return facets && typeof facets === 'object' && !Array.isArray(facets)\n ? (facets as Record<string, Doc>)\n : {}\n}\n\nfunction mapOf(value: unknown): Doc {\n return value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Doc)\n : {}\n}\n\n/**\n * A refusal one record holds for a site, written over the other record's grant\n * for the same site (AGL-3320).\n *\n * The refusal outranks the grant, as it does when one person's refusal and\n * grant sit on two sites of one group: two records of one person who said no\n * to a site are a person who said no to it, and a merge that kept the grant\n * would start mailing them. The grant is kept beside it as `supersededEntry`,\n * so what the survivor held is still on the record.\n *\n * The merge is written with a merging `set`, which keeps every field of the\n * survivor's entry it does not name — so the grant's own fields are named\n * here, empty, or the refusal would read as carrying the grant's disclosure\n * and whoever recorded it.\n */\nfunction refusalOver(refusal: Doc, grant: Doc): Doc {\n const entry: Doc = { ...refusal, supersededEntry: grant }\n for (const key of Object.keys(grant)) {\n if (!(key in entry)) entry[key] = null\n }\n return entry\n}\n\n/**\n * The plan for folding `merged` into `survivor`.\n *\n * Both are the stored documents as read. The survivor's `email` is the\n * identity that stands; the merged record's becomes an alternate.\n */\nexport function planContactMerge(survivor: Doc, merged: Doc): ContactMergePlan {\n const survivorEmail = normalizeContactEmail(survivor['email']) ?? ''\n const mergedEmails = contactEmails(merged)\n const alternates = [\n ...new Set([\n ...strings(survivor[CONTACT_ALTERNATE_EMAILS_FIELD]),\n ...mergedEmails,\n ]),\n ]\n .filter((email) => email !== survivorEmail)\n .slice(0, CONTACT_ALTERNATE_EMAILS_CAP)\n\n const out: Doc = {\n [CONTACT_ALTERNATE_EMAILS_FIELD]: alternates,\n }\n // The canonical name, with its search keys, only when the survivor had none.\n const survivorName = text(survivor['name']).trim()\n const mergedName = text(merged['name']).trim()\n if (!survivorName && mergedName) Object.assign(out, nameSearchFields(mergedName))\n // The search echoes, on the same fill rule.\n for (const key of ['phone', 'companyName'] as const) {\n if (isEmpty(survivor[key]) && !isEmpty(merged[key])) out[key] = merged[key]\n }\n // The shared lists: who may read the row, who captured it, which forms and\n // companies it is filed under.\n const visibleTo = union(survivor['visibleTo'], merged['visibleTo'])\n out['visibleTo'] = visibleTo\n const capturedBy = union(survivor['capturedByHostIds'], merged['capturedByHostIds'])\n if (capturedBy.length) out['capturedByHostIds'] = capturedBy\n const formIds = union(\n survivor[CONTACT_FORM_IDS_FIELD],\n merged[CONTACT_FORM_IDS_FIELD],\n CONTACT_FORM_IDS_CAP,\n )\n if (formIds.length) out[CONTACT_FORM_IDS_FIELD] = formIds\n const survivorCompanies = strings(survivor[CONTACT_COMPANY_IDS_FIELD])\n const mergedCompanies = strings(merged[CONTACT_COMPANY_IDS_FIELD])\n const companyIds = [...new Set([...survivorCompanies, ...mergedCompanies])]\n if (companyIds.length) out[CONTACT_COMPANY_IDS_FIELD] = companyIds\n const companyCounts = survivorCompanies\n .filter((id) => mergedCompanies.includes(id))\n .map((companyId) => ({ companyId, delta: -1 as const }))\n // The organization-level fields the REST API reads and writes.\n const tags = union(survivor['tags'], merged['tags'])\n if (tags.length) out['tags'] = tags\n const notes = joinNotes(survivor['notes'], merged['notes'])\n if (notes) out['notes'] = notes\n if (!isEmpty(survivor['custom']) || !isEmpty(merged['custom'])) {\n out['custom'] = mergeCustom(survivor['custom'], merged['custom'])\n }\n // Consent: a grant fills where the survivor has none for that site, and a\n // refusal on either record stands — per site as well as unscoped (AGL-3320).\n const survivorConsent = mapOf(survivor[MARKETING_CONSENT_BY_HOST_FIELD])\n const mergedConsent = mapOf(merged[MARKETING_CONSENT_BY_HOST_FIELD])\n const consentByHost: Doc = { ...mergedConsent, ...survivorConsent }\n for (const [hostId, value] of Object.entries(mergedConsent)) {\n const refusal = mapOf(value)\n if (refusal[MARKETING_CONSENT_FIELD] !== false) continue\n if (!(hostId in survivorConsent)) continue\n const standing = mapOf(survivorConsent[hostId])\n if (standing[MARKETING_CONSENT_FIELD] === false) continue\n consentByHost[hostId] = refusalOver(refusal, standing)\n }\n if (Object.keys(consentByHost).length) {\n out[MARKETING_CONSENT_BY_HOST_FIELD] = consentByHost\n }\n if (survivor['marketingConsent'] === false || merged['marketingConsent'] === false) {\n out['marketingConsent'] = false\n } else if (survivor['marketingConsent'] === true || merged['marketingConsent'] === true) {\n out['marketingConsent'] = true\n }\n // Each holder's facet — only the groups the merged record held need a write.\n const survivorFacets = facetsOf(survivor)\n const mergedFacets = facetsOf(merged)\n const facets: Record<string, Doc> = {}\n for (const [groupId, facet] of Object.entries(mergedFacets)) {\n facets[groupId] = mergeContactFacet(survivorFacets[groupId], facet)\n }\n if (Object.keys(facets).length) out[CONTACT_FACETS_FIELD] = facets\n\n return {\n survivor: out,\n emails: [survivorEmail, ...alternates].filter(Boolean),\n mergedEmails,\n companyCounts,\n visibleTo,\n }\n}\n\n/** Where a previewed value comes from. */\nexport type ContactMergeSource = 'survivor' | 'merged' | 'both' | 'none'\n\n/** One line of the side-by-side the dialog shows. */\nexport interface ContactMergePreviewRow {\n key: string\n label: string\n /** The surviving record's value, as text. */\n survivor: string\n /** The merged record's value, as text. */\n merged: string\n /** What the survivor carries afterwards. */\n result: string\n from: ContactMergeSource\n}\n\nexport interface ContactMergePreviewOptions {\n /** The owner's display name for a uid; the uid itself when absent. */\n memberName?: (uid: string) => string\n}\n\nfunction addressLine(value: unknown): string {\n const address = mapOf(value)\n return ['line1', 'line2', 'city', 'state', 'postalCode', 'country']\n .map((key) => text(address[key]).trim())\n .filter(Boolean)\n .join(', ')\n}\n\nfunction sourceOf(survivor: string, merged: string, result: string): ContactMergeSource {\n if (!result) return 'none'\n if (survivor && merged && survivor !== merged && result !== survivor && result !== merged) {\n return 'both'\n }\n if (result === survivor && survivor === merged) return 'both'\n return result === survivor ? 'survivor' : 'merged'\n}\n\n/**\n * The merge as the viewing group would see it, field by field.\n *\n * Reads through ONE group's facet, the way the record page does, so a\n * holder previewing a merge sees its own values and never another holder's.\n * Every row carries the survivor's value, the merged record's, and the\n * result the plan would write, so the dialog is a rendering and not a second\n * opinion.\n */\nexport function contactMergePreview(\n survivor: Doc,\n merged: Doc,\n groupId: string,\n options: ContactMergePreviewOptions = {},\n): ContactMergePreviewRow[] {\n const plan = planContactMerge(survivor, merged)\n const after: Doc = {\n ...survivor,\n ...plan.survivor,\n [CONTACT_FACETS_FIELD]: {\n ...facetsOf(survivor),\n ...mapOf(plan.survivor[CONTACT_FACETS_FIELD]),\n },\n }\n const a = readContactFacet(survivor, groupId)\n const b = readContactFacet(merged, groupId)\n const c = readContactFacet(after, groupId)\n const memberName = options.memberName ?? ((uid: string) => uid)\n const stage = (value: unknown) =>\n typeof value === 'string' && value in CONTACT_LIFECYCLE_STAGE_LABELS\n ? CONTACT_LIFECYCLE_STAGE_LABELS[value as keyof typeof CONTACT_LIFECYCLE_STAGE_LABELS]\n : ''\n const owner = (value: unknown) => (text(value) ? memberName(text(value)) : '')\n const list = (value: unknown) => strings(value).join(', ')\n const money = (facet: ContactFacet) =>\n facet.ordersCount\n ? `${facet.ordersCount} · $${((facet.ltvCents ?? 0) / 100).toFixed(2)}`\n : ''\n const row = (\n key: string,\n label: string,\n left: string,\n right: string,\n result: string,\n ): ContactMergePreviewRow => ({\n key,\n label,\n survivor: left,\n merged: right,\n result,\n from: sourceOf(left, right, result),\n })\n const rows: ContactMergePreviewRow[] = [\n row(\n 'name',\n 'Name',\n contactDisplayName(survivor, groupId),\n contactDisplayName(merged, groupId),\n contactDisplayName(after, groupId),\n ),\n row(\n 'email',\n 'Email',\n contactEmails(survivor).join(', '),\n contactEmails(merged).join(', '),\n plan.emails.join(', '),\n ),\n row('phone', 'Phone', text(a.phone), text(b.phone), text(c.phone)),\n row('jobTitle', 'Job title', text(a.jobTitle), text(b.jobTitle), text(c.jobTitle)),\n row(\n 'company',\n 'Company',\n text(a.companyName),\n text(b.companyName),\n text(c.companyName),\n ),\n row(\n 'lifecycleStage',\n 'Stage',\n stage(a.lifecycleStage),\n stage(b.lifecycleStage),\n stage(c.lifecycleStage),\n ),\n row('owner', 'Owner', owner(a.ownerUid), owner(b.ownerUid), owner(c.ownerUid)),\n row('tags', 'Tags', list(a.tags), list(b.tags), list(c.tags)),\n row('notes', 'Notes', text(a.notes), text(b.notes), text(c.notes)),\n row(\n 'address',\n 'Address',\n addressLine(a.address),\n addressLine(b.address),\n addressLine(c.address),\n ),\n row('orders', 'Orders', money(a), money(b), money(c)),\n row(\n 'timeline',\n 'Timeline entries',\n a.interactions.length ? String(a.interactions.length) : '',\n b.interactions.length ? String(b.interactions.length) : '',\n c.interactions.length ? String(c.interactions.length) : '',\n ),\n ]\n const customKeys = [\n ...new Set([...Object.keys(mapOf(a.custom)), ...Object.keys(mapOf(b.custom))]),\n ].sort()\n for (const key of customKeys) {\n const show = (facet: ContactFacet) => {\n const value = mapOf(facet.custom)[key]\n return value === null || value === undefined ? '' : String(value)\n }\n rows.push(row(`custom.${key}`, key, show(a), show(b), show(c)))\n }\n return rows\n}\n"],"names":["CONTACT_ALTERNATE_EMAILS_CAP","CONTACT_ALTERNATE_EMAILS_FIELD","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","CONTACT_INTERACTIONS_CAP","contactDisplayName","contactEmails","normalizeContactEmail","readContactFacet","CONTACT_COMPANY_IDS_FIELD","CONTACT_LIFECYCLE_STAGE_LABELS","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","nameSearchFields","TAGS_CAP","NOTES_CAP","SUM_FIELDS","LATEST_FIELDS","EARLIEST_FIELDS","isEmpty","value","undefined","trim","Array","isArray","length","Object","keys","text","strings","filter","item","number","Number","isFinite","union","a","b","cap","POSITIVE_INFINITY","Set","slice","fill","survivor","merged","joinNotes","interactionKey","entry","type","refId","atMs","hostId","join","mergeInteractions","seen","out","key","has","add","push","sort","mergeCustom","entries","mergeContactFacet","sources","interactions","tags","campaignIds","notes","custom","field","left","right","Math","max","min","facetsOf","doc","facets","mapOf","refusalOver","refusal","grant","supersededEntry","planContactMerge","survivorEmail","mergedEmails","alternates","email","survivorName","mergedName","assign","visibleTo","capturedBy","formIds","survivorCompanies","mergedCompanies","companyIds","companyCounts","id","includes","map","companyId","delta","survivorConsent","mergedConsent","consentByHost","standing","survivorFacets","mergedFacets","groupId","facet","emails","Boolean","addressLine","address","sourceOf","result","contactMergePreview","options","plan","after","c","memberName","uid","stage","owner","list","money","ordersCount","ltvCents","toFixed","row","label","from","rows","phone","jobTitle","companyName","lifecycleStage","ownerUid","String","customKeys","show"],"mappings":"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"}
@@ -295,13 +295,13 @@ export declare const PLUGIN_DOCS_ANCHORS: {
295
295
  readonly commerce: readonly ["#products-hub", "#inventory", "#reserved-stock", "#stock-movements", "#gift-cards", "#recovery-and-alerts", "#orders", "#orders-screen", "#order-statuses", "#order-money-tiles", "#a-lost-dispute", "#shipping--taxes", "#lodging-tax-on-reservations", "#storefront-sales-tax", "#destination-coverage", "#dropshipping", "#related"];
296
296
  readonly commerceEndToEnd: readonly ["#1-connect-payments", "#2-create-products", "#3-design-the-storefront", "#catalog-search-filters-and-sort", "#category-pages", "#the-product-page-template", "#4-what-checkout-does", "#paying-without-leaving-your-site", "#5-run-orders-from-the-console", "#6-subscriptions--the-stripe-portal", "#related"];
297
297
  readonly companies: readonly ["#the-companies-list", "#create-a-company", "#a-companys-page", "#contacts-at-a-company", "#linked-on-capture", "#import-from-csv", "#export-csv", "#deleting-a-company", "#who-can-see-a-company", "#files", "#related"];
298
- readonly consoleTour: readonly ["#the-app-bar", "#in-context-help", "#primary-navigation", "#editing-vs-managing", "#the-sites-list", "#the-status-pill", "#how-the-pill-is-decided", "#your-site-allowance", "#a-sites-dashboard", "#next", "#workspace-settings--notifications", "#the-notifications-feed", "#notification-settings", "#one-kind-at-a-time", "#workspace-and-site-overrides", "#daily-digests", "#alerts-on-this-device"];
298
+ readonly consoleTour: readonly ["#the-app-bar", "#in-context-help", "#filter-and-search", "#primary-navigation", "#editing-vs-managing", "#the-sites-list", "#the-status-pill", "#how-the-pill-is-decided", "#your-site-allowance", "#a-sites-dashboard", "#next", "#workspace-settings--notifications", "#the-notifications-feed", "#notification-settings", "#one-kind-at-a-time", "#workspace-and-site-overrides", "#daily-digests", "#alerts-on-this-device"];
299
299
  readonly contactActivities: readonly ["#four-kinds-of-history", "#campaign-email", "#logging-an-activity", "#meeting-from-a-booking", "#click-to-call", "#sending-an-email", "#delivery-states", "#captured-email", "#where-an-activity-is-visible", "#the-recent-activity-feed", "#related"];
300
300
  readonly contactFields: readonly ["#define-a-field", "#fields-per-record", "#where-values-show", "#save-a-form-field", "#lead-source-values", "#over-the-api", "#retire-restore-delete", "#recompute-next-activity", "#related"];
301
- readonly contactRecord: readonly ["#adding-a-contact-by-hand", "#the-record-page", "#deleting-and-erasing", "#what-each-site-keeps-to-itself", "#merging-two-records", "#likely-duplicates", "#owner", "#last-engaged", "#lifecycle-stages", "#where-the-persons-lead-is", "#finding-a-contact", "#files", "#related"];
302
- readonly contacts: readonly ["#whats-in-the-crm-area", "#unified-ingestion", "#what-each-plan-includes", "#the-contacts-page", "#import-from-csv", "#segments", "#everywhere-the-crm-shows-up", "#capture-replies", "#at-the-organization-level", "#who-can-open-the-crm", "#related"];
301
+ readonly contactRecord: readonly ["#adding-a-contact-by-hand", "#the-record-page", "#deleting-and-erasing", "#what-each-site-keeps-to-itself", "#when-sites-join-or-leave-a-group", "#merging-two-records", "#likely-duplicates", "#owner", "#last-engaged", "#lifecycle-stages", "#where-the-persons-lead-is", "#finding-a-contact", "#files", "#related"];
302
+ readonly contacts: readonly ["#whats-in-the-crm-area", "#unified-ingestion", "#what-each-plan-includes", "#the-contacts-page", "#import-from-csv", "#segments", "#everywhere-the-crm-shows-up", "#capture-replies", "#at-the-organization-level", "#who-can-open-the-crm", "#one-sender-one-crm", "#related"];
303
303
  readonly crmEmailTemplates: readonly ["#templates-and-snippets", "#merge-fields", "#saving", "#managing-templates", "#duplicate-a-template", "#shared-or-personal", "#over-the-rest-api", "#related"];
304
- readonly crmLeads: readonly ["#what-makes-a-lead", "#what-a-lead-holds", "#adding-a-lead-by-hand", "#the-leads-list", "#working-a-lead-from-the-row", "#several-leads-at-once", "#export-csv", "#import-from-csv", "#who-owns-a-lead", "#a-leads-page", "#email-state", "#converting-a-lead", "#unqualifying-a-lead", "#erasing-the-person", "#who-can-do-this", "#related"];
304
+ readonly crmLeads: readonly ["#what-makes-a-lead", "#what-a-lead-holds", "#adding-a-lead-by-hand", "#the-leads-list", "#filter-the-leads", "#working-a-lead-from-the-row", "#several-leads-at-once", "#export-csv", "#import-from-csv", "#who-owns-a-lead", "#a-leads-page", "#email-state", "#converting-a-lead", "#unqualifying-a-lead", "#erasing-the-person", "#who-can-do-this", "#related"];
305
305
  readonly crmReports: readonly ["#choosing-a-period", "#contacts", "#sources-and-lifecycle", "#conversion-by-source", "#lead-funnel", "#pipeline", "#forecast-by-close-month", "#won-and-lost", "#won-and-lost-by-owner", "#activity-by-teammate", "#tasks", "#exporting-a-table", "#crm-at-a-glance", "#how-the-numbers-are-counted", "#related"];
306
306
  readonly crmSettings: readonly ["#companies", "#create-companies-from-work-email-domains", "#default-owner", "#assignment-rules", "#round-robin", "#email-templates", "#email-capture", "#your-sending-addresses", "#recipes", "#related"];
307
307
  readonly crmTasks: readonly ["#the-tasks-page", "#the-calendar-view", "#snoozing-a-task", "#selecting-exporting-and-acting-on-many", "#import-from-csv", "#creating-a-task", "#assigning-a-task-to-someone-else", "#completing-and-reopening", "#organization-tasks", "#tasks-on-a-contact-company-or-deal", "#reminders", "#turning-reminders-off", "#next-activity", "#the-daily-digest", "#turning-it-off", "#the-dashboard-card", "#who-can-do-what", "#related"];
@@ -309,9 +309,9 @@ export declare const PLUGIN_DOCS_ANCHORS: {
309
309
  readonly datasets: readonly ["#model-builder", "#typed-documents", "#filter-records", "#relations", "#query-layer", "#repeatable-components", "#who-a-dataset-is-shared-with", "#import--export", "#related"];
310
310
  readonly deals: readonly ["#pipelines", "#stages", "#the-board-and-the-table", "#import-from-csv", "#creating-a-deal", "#line-items", "#moving-winning-and-losing", "#a-won-deal-makes-its-contact-a-customer", "#a-deals-page", "#files", "#related"];
311
311
  readonly designedEmails: readonly ["#create-a-template", "#find-a-template", "#duplicate-a-template", "#styling-email-blocks", "#merge-tokens", "#send-it", "#the-plain-text-version", "#start-from-a-brief-instead"];
312
- readonly emailCampaigns: readonly ["#send-a-campaign", "#campaigns-belong-to-the-organization", "#organization-emails-page", "#campaigns-group-emails", "#filter-the-lists", "#what-belongs-to-a-campaign", "#who-the-email-comes-from", "#sending-domains", "#account-email-always-sends", "#marketing-needs-a-domain", "#two-ways-to-get-a-domain", "#a-domain-we-set-up-is-a-request", "#domain-states", "#senders", "#send-a-test", "#preview-the-email", "#monthly-send-cap", "#personalize-with-merge-tags", "#recipient-count", "#who-a-campaign-is-allowed-to-reach", "#schedule-a-send", "#duplicate-an-email", "#email-lists", "#manual-lists", "#list-members", "#add-to-a-list", "#import-a-list", "#remove-from-a-list", "#lists-built-from-a-rule", "#experiments", "#experiments-across-sites", "#opens--clicks", "#the-campaign-report", "#per-contact-engagement", "#which-links-were-clicked", "#revenue-from-a-campaign", "#conversions", "#compliance", "#list-unsubscribe", "#topics", "#preference-page", "#frequency-opt-down", "#double-opt-in", "#consent-group-confirmation", "#marketing-mail", "#frequency-cap", "#suppressions", "#add-a-suppression", "#platform-suppressions", "#related"];
312
+ readonly emailCampaigns: readonly ["#send-a-campaign", "#campaigns-belong-to-the-organization", "#organization-emails-page", "#campaigns-group-emails", "#filter-the-lists", "#what-belongs-to-a-campaign", "#who-the-email-comes-from", "#sending-domains", "#account-email-always-sends", "#marketing-needs-a-domain", "#two-ways-to-get-a-domain", "#a-domain-we-set-up-is-a-request", "#domain-states", "#senders", "#send-a-test", "#preview-the-email", "#monthly-send-cap", "#personalize-with-merge-tags", "#recipient-count", "#who-a-campaign-is-allowed-to-reach", "#schedule-a-send", "#duplicate-an-email", "#email-lists", "#manual-lists", "#list-members", "#add-to-a-list", "#import-a-list", "#remove-from-a-list", "#lists-built-from-a-rule", "#experiments", "#experiments-across-sites", "#opens--clicks", "#the-campaign-report", "#per-contact-engagement", "#which-links-were-clicked", "#revenue-from-a-campaign", "#conversions", "#compliance", "#list-unsubscribe", "#topics", "#preference-page", "#consent-groups", "#consent-group-create", "#consent-group-join", "#consent-group-leave", "#consent-group-rename", "#consent-group-progress", "#frequency-opt-down", "#double-opt-in", "#consent-group-confirmation", "#marketing-mail", "#frequency-cap", "#suppressions", "#add-a-suppression", "#platform-suppressions", "#related"];
313
313
  readonly events: readonly ["#manage-events", "#show-events-on-a-screen", "#search-engines", "#related"];
314
- readonly forms: readonly ["#reading-submissions-from-code", "#build-a-form", "#monthly-allowance-per-plan", "#spam-and-abuse-protection", "#the-per-site-monthly-ceiling", "#field-types", "#labels-and-placeholders", "#example-a-quick-survey", "#after-submit", "#example-grow-an-email-list-from-a-signup-form", "#where-submissions-go", "#the-inbox", "#who-a-submission-is-from", "#where-this-one-went", "#replying-to-a-submission", "#every-sites-inbox-at-once", "#one-forms-own-page", "#find-a-form", "#duplicate-a-form", "#switch-forms-off-for-one-site", "#related"];
314
+ readonly forms: readonly ["#reading-submissions-from-code", "#build-a-form", "#monthly-allowance-per-plan", "#spam-and-abuse-protection", "#the-per-site-monthly-ceiling", "#field-types", "#labels-and-placeholders", "#example-a-quick-survey", "#after-submit", "#example-grow-an-email-list-from-a-signup-form", "#consent-group-disclosure", "#where-submissions-go", "#the-inbox", "#filter-the-inbox", "#who-a-submission-is-from", "#where-this-one-went", "#replying-to-a-submission", "#every-sites-inbox-at-once", "#one-forms-own-page", "#find-a-form", "#duplicate-a-form", "#switch-forms-off-for-one-site", "#related"];
315
315
  readonly installYourFirstPlugin: readonly ["#before-you-start", "#step-1-open", "#step-2-browse", "#step-3-reviews", "#step-4-targeting", "#step-5-install", "#step-6-use", "#step-7-off", "#what-to-do-next", "#related"];
316
316
  readonly inviteTeammates: readonly ["#invite-someone", "#pending-invites", "#who-gets-told", "#accepting-an-invite", "#an-invitation-never-changes-who-owns-the-workspace", "#how-team-members-act", "#you-are-a-site-collaborators-support-channel", "#help-a-teammate-who-is-locked-out", "#why-you-cant-always-set-a-password", "#activity-log", "#ai-actions", "#ai-usage", "#ai-allotment", "#tips", "#related"];
317
317
  readonly manifestAndEnvs: readonly ["#plugin-manifest-published-with-every-version", "#contributes--where-the-plugin-loads", "#config--settings-without-writing-a-settings-screen", "#listing--version-documents", "#review--trust-lifecycle", "#environment-variables", "#pluginsconfigjson-first-party-contributors"];
@@ -324,7 +324,7 @@ export declare const PLUGIN_DOCS_ANCHORS: {
324
324
  readonly publisherHandbook: readonly ["#before-your-first-publish", "#the-publisher-agreement", "#where-to-publish-from", "#what-installing-each-type-does", "#rules-an-email-starter-has-to-meet", "#publishing-a-version", "#before-you-publish", "#review-what-happens-after-you-publish", "#the-two-badges-and-what-each-one-promises", "#asking-to-be-verified", "#testing-a-version-before-it-is-approved", "#watching-your-own-submission", "#disabled-versions", "#private-plugins", "#authoring-your-listing", "#what-your-listing-can-say-about-aglyn", "#versioning--updates", "#shipping-a-new-version", "#how-installs-work-the-buyer-side", "#getting-paid", "#low-prices-and-processing"];
325
325
  readonly redirects: readonly ["#manage-redirects", "#sending-visitors-to-another-site", "#metrics", "#match-modes-v2", "#related"];
326
326
  readonly sandboxSecurity: readonly ["#a-separate-origin", "#per-manifest-network-policy", "#when-you-cant-declare-the-origin", "#pinned-immutable-artifacts", "#what-this-means-when-you-build", "#related"];
327
- readonly sequences: readonly ["#what-it-is-for", "#sent-from-your-own-mailbox", "#where-it-lives", "#connect-a-mailbox", "#send-as", "#daily-cap", "#mailbox-status", "#auto-pause", "#mailbox-actions", "#link-domains", "#compliance-settings", "#allowed-countries", "#do-not-contact-domains", "#sequences", "#build-a-sequence", "#sequence-status", "#enroll", "#cold-contacts", "#enrollments", "#curate", "#sending", "#what-stops-a-sequence", "#unsubscribe", "#related"];
327
+ readonly sequences: readonly ["#what-it-is-for", "#sent-from-your-own-mailbox", "#where-it-lives", "#connect-a-mailbox", "#send-as", "#daily-cap", "#mailbox-status", "#auto-pause", "#mailbox-actions", "#link-domains", "#compliance-settings", "#allowed-countries", "#do-not-contact-domains", "#sequences", "#build-a-sequence", "#send-a-test", "#sequence-status", "#enroll", "#cold-contacts", "#enrollments", "#curate", "#sending", "#what-stops-a-sequence", "#unsubscribe", "#related"];
328
328
  readonly webhooks: readonly ["#outbound-webhooks", "#inbound-webhooks", "#tips", "#related"];
329
329
  };
330
330
  type PluginAnchorMap = typeof PLUGIN_DOCS_ANCHORS;
@@ -523,6 +523,7 @@ export const PLUGIN_DOCS_ANCHORS = {
523
523
  consoleTour: [
524
524
  '#the-app-bar',
525
525
  '#in-context-help',
526
+ '#filter-and-search',
526
527
  '#primary-navigation',
527
528
  '#editing-vs-managing',
528
529
  '#the-sites-list',
@@ -568,6 +569,7 @@ export const PLUGIN_DOCS_ANCHORS = {
568
569
  '#the-record-page',
569
570
  '#deleting-and-erasing',
570
571
  '#what-each-site-keeps-to-itself',
572
+ '#when-sites-join-or-leave-a-group',
571
573
  '#merging-two-records',
572
574
  '#likely-duplicates',
573
575
  '#owner',
@@ -589,6 +591,7 @@ export const PLUGIN_DOCS_ANCHORS = {
589
591
  '#capture-replies',
590
592
  '#at-the-organization-level',
591
593
  '#who-can-open-the-crm',
594
+ '#one-sender-one-crm',
592
595
  '#related'
593
596
  ],
594
597
  crmEmailTemplates: [
@@ -606,6 +609,7 @@ export const PLUGIN_DOCS_ANCHORS = {
606
609
  '#what-a-lead-holds',
607
610
  '#adding-a-lead-by-hand',
608
611
  '#the-leads-list',
612
+ '#filter-the-leads',
609
613
  '#working-a-lead-from-the-row',
610
614
  '#several-leads-at-once',
611
615
  '#export-csv',
@@ -755,6 +759,12 @@ export const PLUGIN_DOCS_ANCHORS = {
755
759
  '#list-unsubscribe',
756
760
  '#topics',
757
761
  '#preference-page',
762
+ '#consent-groups',
763
+ '#consent-group-create',
764
+ '#consent-group-join',
765
+ '#consent-group-leave',
766
+ '#consent-group-rename',
767
+ '#consent-group-progress',
758
768
  '#frequency-opt-down',
759
769
  '#double-opt-in',
760
770
  '#consent-group-confirmation',
@@ -782,8 +792,10 @@ export const PLUGIN_DOCS_ANCHORS = {
782
792
  '#example-a-quick-survey',
783
793
  '#after-submit',
784
794
  '#example-grow-an-email-list-from-a-signup-form',
795
+ '#consent-group-disclosure',
785
796
  '#where-submissions-go',
786
797
  '#the-inbox',
798
+ '#filter-the-inbox',
787
799
  '#who-a-submission-is-from',
788
800
  '#where-this-one-went',
789
801
  '#replying-to-a-submission',
@@ -947,6 +959,7 @@ export const PLUGIN_DOCS_ANCHORS = {
947
959
  '#do-not-contact-domains',
948
960
  '#sequences',
949
961
  '#build-a-sequence',
962
+ '#send-a-test',
950
963
  '#sequence-status',
951
964
  '#enroll',
952
965
  '#cold-contacts',