@aglyn/aglyn 1.0.0-beta.189 → 1.0.0-beta.191

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.189",
3
+ "version": "1.0.0-beta.191",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -33,16 +33,16 @@
33
33
  "./package.json": "./package.json"
34
34
  },
35
35
  "dependencies": {
36
- "@aglyn/shared-data-enums": "1.0.0-beta.189",
37
- "@aglyn/shared-data-mdi": "1.0.0-beta.189",
38
- "@aglyn/shared-data-types": "1.0.0-beta.189",
39
- "@aglyn/shared-util-email": "1.0.0-beta.189",
40
- "@aglyn/shared-util-first-touch": "1.0.0-beta.189",
41
- "@aglyn/shared-util-http": "1.0.0-beta.189",
42
- "@aglyn/shared-util-logger": "1.0.0-beta.189",
43
- "@aglyn/shared-util-timestamp": "1.0.0-beta.189",
44
- "@aglyn/shared-util-tools": "1.0.0-beta.189",
45
- "@aglyn/shared-util-vendor": "1.0.0-beta.189",
36
+ "@aglyn/shared-data-enums": "1.0.0-beta.191",
37
+ "@aglyn/shared-data-mdi": "1.0.0-beta.191",
38
+ "@aglyn/shared-data-types": "1.0.0-beta.191",
39
+ "@aglyn/shared-util-email": "1.0.0-beta.191",
40
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.191",
41
+ "@aglyn/shared-util-http": "1.0.0-beta.191",
42
+ "@aglyn/shared-util-logger": "1.0.0-beta.191",
43
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.191",
44
+ "@aglyn/shared-util-tools": "1.0.0-beta.191",
45
+ "@aglyn/shared-util-vendor": "1.0.0-beta.191",
46
46
  "@data-driven-forms/react-form-renderer": "^4.2.0",
47
47
  "@msgpack/msgpack": "^3.1.3",
48
48
  "@types/unist": "^3.0.3",
@@ -70,6 +70,22 @@
70
70
  * (`marketing-consent.ts`) — and the preference pages lift one across the
71
71
  * group, since a person rejoining the sender has rejoined all of it.
72
72
  *
73
+ * ## A pending CONFIRMATION is the asking site's, unless the org says the group waits
74
+ *
75
+ * A site that asks for a confirmation click holds its own mail on that stream
76
+ * until the click comes. A pending question is not a refusal, so by default
77
+ * the rule above does not carry it: the sibling sites go on mailing. An org
78
+ * may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every
79
+ * site of a declared group waits for the click the way the asking site does —
80
+ * a pending entry on any of them holds the stream from all of them, read
81
+ * across the CURRENT group exactly as a refusal is.
82
+ *
83
+ * The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),
84
+ * so a send path already holding the group holds the answer and reads nothing
85
+ * more for it. A group of one never waits on anybody: there is nobody else.
86
+ * Off is the default and the absence, and an org that never set it reads
87
+ * exactly the documents it read before the field existed.
88
+ *
73
89
  * ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A
74
90
  * refusal is stored on the site the person acted on, so once that site is no
75
91
  * longer named, its former siblings stop seeing a refusal that was given to
@@ -85,6 +101,7 @@
85
101
  * are resolved by different functions on purpose, and neither reads the
86
102
  * other.
87
103
  */
104
+ import type { TopicSubscriptionState } from './email-topics';
88
105
  import { type ScopeToken } from './scope-tokens';
89
106
  /**
90
107
  * The org field holding the declarations:
@@ -96,6 +113,17 @@ import { type ScopeToken } from './scope-tokens';
96
113
  * and there is no reading of that which is safe to pool on.
97
114
  */
98
115
  export declare const CONSENT_GROUPS_FIELD = "consentGroups";
116
+ /**
117
+ * The org field that makes a declared group's sites wait for each other's
118
+ * confirmation click (AGL-3316): `true` is on; absent is off.
119
+ *
120
+ * One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a
121
+ * flag on each declaration, because a declaration decides WHICH sites are one
122
+ * sender and this decides how the org treats a question one of them has put.
123
+ * Written only by `/api/orgs/settings`; the rules deny both fields to every
124
+ * client.
125
+ */
126
+ export declare const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD = "consentGroupsAwaitConfirmation";
99
127
  /**
100
128
  * The most sites one group may name.
101
129
  *
@@ -141,6 +169,13 @@ export interface ConsentGroup {
141
169
  hostIds: string[];
142
170
  /** False for the implicit group of one. */
143
171
  declared: boolean;
172
+ /**
173
+ * Whether a confirmation one site of the group is waiting on holds the
174
+ * other sites' mail too — the org's
175
+ * {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD}, for a declared group.
176
+ * Always `false` for a group of one, which has no other site to hold.
177
+ */
178
+ awaitsConfirmation: boolean;
144
179
  }
145
180
  /**
146
181
  * The group of one — a site that has declared no pooling.
@@ -150,6 +185,13 @@ export interface ConsentGroup {
150
185
  * a helper decide what it covers.
151
186
  */
152
187
  export declare function soloConsentGroup(hostId: string): ConsentGroup;
188
+ /**
189
+ * Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.
190
+ *
191
+ * Only a stored `true` is on. Absent, `false`, or anything a stray write left
192
+ * is off — the state every org was in before the field existed.
193
+ */
194
+ export declare function consentGroupsAwaitConfirmation(org: Record<string, unknown> | null | undefined): boolean;
153
195
  /**
154
196
  * Reads the declared groups off an org document, dropping every entry that
155
197
  * cannot be honored.
@@ -185,10 +227,23 @@ export declare function consentGroupForHost(org: Record<string, unknown> | null
185
227
  * Every site the group names, because a refusal filed against any of them is
186
228
  * a refusal of the sender — see "OPT-OUT runs the other way" above. The
187
229
  * sending site leads so a reader that treats its own record differently from
188
- * a sibling's (a pending confirmation is the sending site's own concern) can
189
- * tell them apart by position. A group of one is the site alone.
230
+ * a sibling's (a sibling's pending confirmation holds the send only when the
231
+ * group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by
232
+ * position. A group of one is the site alone.
190
233
  */
191
234
  export declare function consentGroupOptOutHosts(group: Pick<ConsentGroup, 'hostId' | 'hostIds'>): string[];
235
+ /**
236
+ * The sending site's standing on one stream, read across its group.
237
+ *
238
+ * `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,
239
+ * in that order, so the sending site's comes first. A refusal on any of them
240
+ * is a refusal of the sender. The sending site's own pending confirmation
241
+ * holds its mail; a sibling's holds it only when the group
242
+ * {@link ConsentGroup.awaitsConfirmation}. The precedence is the one
243
+ * `readTopicSubscriptionState` keeps within one entry: a refusal outranks a
244
+ * pending question, and an expired question is still pending.
245
+ */
246
+ export declare function consentGroupTopicState(group: Pick<ConsentGroup, 'awaitsConfirmation'>, states: readonly TopicSubscriptionState[]): TopicSubscriptionState;
192
247
  /**
193
248
  * The `visibleTo` a resource captured by this group starts with.
194
249
  *
@@ -69,6 +69,22 @@
69
69
  * (`marketing-consent.ts`) — and the preference pages lift one across the
70
70
  * group, since a person rejoining the sender has rejoined all of it.
71
71
  *
72
+ * ## A pending CONFIRMATION is the asking site's, unless the org says the group waits
73
+ *
74
+ * A site that asks for a confirmation click holds its own mail on that stream
75
+ * until the click comes. A pending question is not a refusal, so by default
76
+ * the rule above does not carry it: the sibling sites go on mailing. An org
77
+ * may turn {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on, and then every
78
+ * site of a declared group waits for the click the way the asking site does —
79
+ * a pending entry on any of them holds the stream from all of them, read
80
+ * across the CURRENT group exactly as a refusal is.
81
+ *
82
+ * The switch is resolved INTO the group ({@link ConsentGroup.awaitsConfirmation}),
83
+ * so a send path already holding the group holds the answer and reads nothing
84
+ * more for it. A group of one never waits on anybody: there is nobody else.
85
+ * Off is the default and the absence, and an org that never set it reads
86
+ * exactly the documents it read before the field existed.
87
+ *
72
88
  * ⚠️ The one change that runs AGAINST reading is a site LEAVING a group. A
73
89
  * refusal is stored on the site the person acted on, so once that site is no
74
90
  * longer named, its former siblings stop seeing a refusal that was given to
@@ -93,6 +109,16 @@
93
109
  * site A naming a group that site B has left is a half-declared controller,
94
110
  * and there is no reading of that which is safe to pool on.
95
111
  */ export const CONSENT_GROUPS_FIELD = 'consentGroups';
112
+ /**
113
+ * The org field that makes a declared group's sites wait for each other's
114
+ * confirmation click (AGL-3316): `true` is on; absent is off.
115
+ *
116
+ * One switch for the org beside {@link CONSENT_GROUPS_FIELD} rather than a
117
+ * flag on each declaration, because a declaration decides WHICH sites are one
118
+ * sender and this decides how the org treats a question one of them has put.
119
+ * Written only by `/api/orgs/settings`; the rules deny both fields to every
120
+ * client.
121
+ */ export const CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD = 'consentGroupsAwaitConfirmation';
96
122
  /**
97
123
  * The most sites one group may name.
98
124
  *
@@ -118,9 +144,18 @@
118
144
  hostIds: [
119
145
  hostId
120
146
  ],
121
- declared: false
147
+ declared: false,
148
+ awaitsConfirmation: false
122
149
  };
123
150
  }
151
+ /**
152
+ * Whether the org turned {@link CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD} on.
153
+ *
154
+ * Only a stored `true` is on. Absent, `false`, or anything a stray write left
155
+ * is off — the state every org was in before the field existed.
156
+ */ export function consentGroupsAwaitConfirmation(org) {
157
+ return (org != null ? org : {})[CONSENT_GROUPS_AWAIT_CONFIRMATION_FIELD] === true;
158
+ }
124
159
  /**
125
160
  * Reads the declared groups off an org document, dropping every entry that
126
161
  * cannot be honored.
@@ -198,7 +233,8 @@
198
233
  hostIds: [
199
234
  ...group.hostIds
200
235
  ],
201
- declared: true
236
+ declared: true,
237
+ awaitsConfirmation: consentGroupsAwaitConfirmation(org)
202
238
  };
203
239
  }
204
240
  return soloConsentGroup(hostId);
@@ -210,8 +246,9 @@
210
246
  * Every site the group names, because a refusal filed against any of them is
211
247
  * a refusal of the sender — see "OPT-OUT runs the other way" above. The
212
248
  * sending site leads so a reader that treats its own record differently from
213
- * a sibling's (a pending confirmation is the sending site's own concern) can
214
- * tell them apart by position. A group of one is the site alone.
249
+ * a sibling's (a sibling's pending confirmation holds the send only when the
250
+ * group {@link ConsentGroup.awaitsConfirmation}) can tell them apart by
251
+ * position. A group of one is the site alone.
215
252
  */ export function consentGroupOptOutHosts(group) {
216
253
  var _group_hostIds;
217
254
  const siblings = ((_group_hostIds = group.hostIds) != null ? _group_hostIds : []).filter((id)=>typeof id === 'string' && id && id !== group.hostId);
@@ -220,6 +257,22 @@
220
257
  ...new Set(siblings)
221
258
  ];
222
259
  }
260
+ /**
261
+ * The sending site's standing on one stream, read across its group.
262
+ *
263
+ * `states` are the stream's states on {@link consentGroupOptOutHosts}' sites,
264
+ * in that order, so the sending site's comes first. A refusal on any of them
265
+ * is a refusal of the sender. The sending site's own pending confirmation
266
+ * holds its mail; a sibling's holds it only when the group
267
+ * {@link ConsentGroup.awaitsConfirmation}. The precedence is the one
268
+ * `readTopicSubscriptionState` keeps within one entry: a refusal outranks a
269
+ * pending question, and an expired question is still pending.
270
+ */ export function consentGroupTopicState(group, states) {
271
+ const [own = 'subscribed', ...siblings] = states;
272
+ if (own === 'opted-out' || siblings.includes('opted-out')) return 'opted-out';
273
+ if (own === 'pending') return 'pending';
274
+ return group.awaitsConfirmation === true && siblings.includes('pending') ? 'pending' : 'subscribed';
275
+ }
223
276
  /**
224
277
  * The `visibleTo` a resource captured by this group starts with.
225
278
  *
@@ -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 * ⚠️ 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 { 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 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\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 }\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 }\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 pending confirmation is the sending site's own concern) can\n * tell them apart by 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 `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","MAX_CONSENT_GROUP_HOSTS","soloConsentGroup","hostId","Error","groupId","name","hostIds","declared","readConsentGroups","org","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","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 * ## 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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"}
@@ -27,7 +27,7 @@
27
27
  * feature plugin (AGL-2662).
28
28
  */ import { CONTACT_FACETS_FIELD } from "./contacts.js";
29
29
  import { CAPTURED_BY_HOST_FIELD } from "./marketing-consent.js";
30
- import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./consent-groups.js";
30
+ import { consentGroupForHost, consentGroupsAwaitConfirmation, readConsentGroups, soloConsentGroup } from "./consent-groups.js";
31
31
  /**
32
32
  * The holder of nothing: the group a contact is flattened through when no
33
33
  * site has captured it and no facet exists — a row written by a path that
@@ -44,7 +44,8 @@ import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./cons
44
44
  groupId: '',
45
45
  name: null,
46
46
  hostIds: [],
47
- declared: false
47
+ declared: false,
48
+ awaitsConfirmation: false
48
49
  });
49
50
  /**
50
51
  * The sites that captured this person, in CAPTURE order — the order
@@ -115,7 +116,8 @@ import { consentGroupForHost, readConsentGroups, soloConsentGroup } from "./cons
115
116
  hostIds: [
116
117
  ...declared.hostIds
117
118
  ],
118
- declared: true
119
+ declared: true,
120
+ awaitsConfirmation: consentGroupsAwaitConfirmation(org)
119
121
  };
120
122
  }
121
123
  return soloConsentGroup(key);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-holder.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 * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport { CONTACT_FACETS_FIELD } from './contacts'\nimport { CAPTURED_BY_HOST_FIELD } from './marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n readConsentGroups,\n soloConsentGroup,\n} from './consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n}) as ConsentGroup\n\n/**\n * The sites that captured this person, in CAPTURE order — the order\n * `arrayUnion` left, so the first entry is the site that met them first.\n *\n * `contactCaptureHostIds` sorts the same array for RENDERING, where a\n * stable order matters more than a first. Both readers below want the\n * first, so neither may use the sorted one.\n */\nfunction captureOrder(\n record: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (record ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n}\n\n/**\n * The group a LEAD is read and acted on through (AGL-3278).\n *\n * The same question {@link contactPrimaryGroup} answers, with a shorter\n * answer: a lead is one flat document — no facets, one profile, one\n * status — so there is nothing to choose between and the holder is simply\n * the site that captured the person first. What it is FOR is the same: at\n * the organization level the record page has no viewing site, and it needs\n * one to read the consent basis against, to file an activity under, to\n * offer a booking on and to convert as.\n *\n * A lead no site has captured — a row imported before attribution — reads\n * through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface\n * asks \"is there a site here at all\" without testing for an empty string.\n */\nexport function leadPrimaryGroup(\n lead: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const captured = captureOrder(lead)\n return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP\n}\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n const captured = captureOrder(record)\n for (const hostId of captured) {\n const group = consentGroupForHost(org, hostId)\n if (facetKeys.includes(group.groupId)) return group\n }\n for (const key of facetKeys) {\n const declared = readConsentGroups(org)[key]\n if (declared) {\n return {\n hostId: declared.hostIds[0],\n groupId: key,\n name: declared.name,\n hostIds: [...declared.hostIds],\n declared: true,\n }\n }\n return soloConsentGroup(key)\n }\n if (captured.length) return consentGroupForHost(org, captured[0])\n return NO_HOLDER_GROUP\n}"],"names":["CONTACT_FACETS_FIELD","CAPTURED_BY_HOST_FIELD","consentGroupForHost","readConsentGroups","soloConsentGroup","NO_HOLDER_GROUP","Object","freeze","hostId","groupId","name","hostIds","declared","captureOrder","record","raw","Array","isArray","map","id","String","trim","filter","Boolean","leadPrimaryGroup","lead","org","captured","length","contactPrimaryGroup","row","facets","facetKeys","keys","group","includes","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SAASA,oBAAoB,QAAQ,gBAAY;AACjD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAEEC,mBAAmB,EACnBC,iBAAiB,EACjBC,gBAAgB,QACX,sBAAkB;AAEzB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,kBAAgCC,OAAOC,MAAM,CAAC;IACzDC,QAAQ;IACRC,SAAS;IACTC,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;AACZ,GAAkB;AAElB;;;;;;;CAOC,GACD,SAASC,aACPC,MAAkD;IAElD,MAAMC,MAAM,CAACD,iBAAAA,SAAU,CAAC,EAAE,CAACb,uBAAuB;IAClD,IAAI,CAACe,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,OAAOA,IAAIG,GAAG,CAAC,CAACC,KAAOC,OAAOD,aAAAA,KAAM,IAAIE,IAAI,IAAIC,MAAM,CAACC;AACzD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,iBACdC,IAAgD,EAChDC,GAA+C;IAE/C,MAAMC,WAAWd,aAAaY;IAC9B,OAAOE,SAASC,MAAM,GAAG1B,oBAAoBwB,KAAKC,QAAQ,CAAC,EAAE,IAAItB;AACnE;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASwB,oBACdC,GAA+C,EAC/CJ,GAA+C;IAE/C,MAAMZ,SAASgB,cAAAA,MAAO,CAAC;IACvB,MAAMC,SAASjB,MAAM,CAACd,qBAAqB;IAC3C,MAAMgC,YACJD,UAAU,OAAOA,WAAW,YAAY,CAACf,MAAMC,OAAO,CAACc,UACnDzB,OAAO2B,IAAI,CAACF,UACZ,EAAE;IACR,MAAMJ,WAAWd,aAAaC;IAC9B,KAAK,MAAMN,UAAUmB,SAAU;QAC7B,MAAMO,QAAQhC,oBAAoBwB,KAAKlB;QACvC,IAAIwB,UAAUG,QAAQ,CAACD,MAAMzB,OAAO,GAAG,OAAOyB;IAChD;IACA,KAAK,MAAME,OAAOJ,UAAW;QAC3B,MAAMpB,WAAWT,kBAAkBuB,IAAI,CAACU,IAAI;QAC5C,IAAIxB,UAAU;YACZ,OAAO;gBACLJ,QAAQI,SAASD,OAAO,CAAC,EAAE;gBAC3BF,SAAS2B;gBACT1B,MAAME,SAASF,IAAI;gBACnBC,SAAS;uBAAIC,SAASD,OAAO;iBAAC;gBAC9BC,UAAU;YACZ;QACF;QACA,OAAOR,iBAAiBgC;IAC1B;IACA,IAAIT,SAASC,MAAM,EAAE,OAAO1B,oBAAoBwB,KAAKC,QAAQ,CAAC,EAAE;IAChE,OAAOtB;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/contact-holder.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 * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport { CONTACT_FACETS_FIELD } from './contacts'\nimport { CAPTURED_BY_HOST_FIELD } from './marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n consentGroupsAwaitConfirmation,\n readConsentGroups,\n soloConsentGroup,\n} from './consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n awaitsConfirmation: false,\n}) as ConsentGroup\n\n/**\n * The sites that captured this person, in CAPTURE order — the order\n * `arrayUnion` left, so the first entry is the site that met them first.\n *\n * `contactCaptureHostIds` sorts the same array for RENDERING, where a\n * stable order matters more than a first. Both readers below want the\n * first, so neither may use the sorted one.\n */\nfunction captureOrder(\n record: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (record ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n}\n\n/**\n * The group a LEAD is read and acted on through (AGL-3278).\n *\n * The same question {@link contactPrimaryGroup} answers, with a shorter\n * answer: a lead is one flat document — no facets, one profile, one\n * status — so there is nothing to choose between and the holder is simply\n * the site that captured the person first. What it is FOR is the same: at\n * the organization level the record page has no viewing site, and it needs\n * one to read the consent basis against, to file an activity under, to\n * offer a booking on and to convert as.\n *\n * A lead no site has captured — a row imported before attribution — reads\n * through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface\n * asks \"is there a site here at all\" without testing for an empty string.\n */\nexport function leadPrimaryGroup(\n lead: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const captured = captureOrder(lead)\n return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP\n}\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n const captured = captureOrder(record)\n for (const hostId of captured) {\n const group = consentGroupForHost(org, hostId)\n if (facetKeys.includes(group.groupId)) return group\n }\n for (const key of facetKeys) {\n const declared = readConsentGroups(org)[key]\n if (declared) {\n return {\n hostId: declared.hostIds[0],\n groupId: key,\n name: declared.name,\n hostIds: [...declared.hostIds],\n declared: true,\n awaitsConfirmation: consentGroupsAwaitConfirmation(org),\n }\n }\n return soloConsentGroup(key)\n }\n if (captured.length) return consentGroupForHost(org, captured[0])\n return NO_HOLDER_GROUP\n}"],"names":["CONTACT_FACETS_FIELD","CAPTURED_BY_HOST_FIELD","consentGroupForHost","consentGroupsAwaitConfirmation","readConsentGroups","soloConsentGroup","NO_HOLDER_GROUP","Object","freeze","hostId","groupId","name","hostIds","declared","awaitsConfirmation","captureOrder","record","raw","Array","isArray","map","id","String","trim","filter","Boolean","leadPrimaryGroup","lead","org","captured","length","contactPrimaryGroup","row","facets","facetKeys","keys","group","includes","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SAASA,oBAAoB,QAAQ,gBAAY;AACjD,SAASC,sBAAsB,QAAQ,yBAAqB;AAC5D,SAEEC,mBAAmB,EACnBC,8BAA8B,EAC9BC,iBAAiB,EACjBC,gBAAgB,QACX,sBAAkB;AAEzB;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,kBAAgCC,OAAOC,MAAM,CAAC;IACzDC,QAAQ;IACRC,SAAS;IACTC,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;IACVC,oBAAoB;AACtB,GAAkB;AAElB;;;;;;;CAOC,GACD,SAASC,aACPC,MAAkD;IAElD,MAAMC,MAAM,CAACD,iBAAAA,SAAU,CAAC,EAAE,CAACf,uBAAuB;IAClD,IAAI,CAACiB,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,OAAOA,IAAIG,GAAG,CAAC,CAACC,KAAOC,OAAOD,aAAAA,KAAM,IAAIE,IAAI,IAAIC,MAAM,CAACC;AACzD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,iBACdC,IAAgD,EAChDC,GAA+C;IAE/C,MAAMC,WAAWd,aAAaY;IAC9B,OAAOE,SAASC,MAAM,GAAG5B,oBAAoB0B,KAAKC,QAAQ,CAAC,EAAE,IAAIvB;AACnE;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASyB,oBACdC,GAA+C,EAC/CJ,GAA+C;IAE/C,MAAMZ,SAASgB,cAAAA,MAAO,CAAC;IACvB,MAAMC,SAASjB,MAAM,CAAChB,qBAAqB;IAC3C,MAAMkC,YACJD,UAAU,OAAOA,WAAW,YAAY,CAACf,MAAMC,OAAO,CAACc,UACnD1B,OAAO4B,IAAI,CAACF,UACZ,EAAE;IACR,MAAMJ,WAAWd,aAAaC;IAC9B,KAAK,MAAMP,UAAUoB,SAAU;QAC7B,MAAMO,QAAQlC,oBAAoB0B,KAAKnB;QACvC,IAAIyB,UAAUG,QAAQ,CAACD,MAAM1B,OAAO,GAAG,OAAO0B;IAChD;IACA,KAAK,MAAME,OAAOJ,UAAW;QAC3B,MAAMrB,WAAWT,kBAAkBwB,IAAI,CAACU,IAAI;QAC5C,IAAIzB,UAAU;YACZ,OAAO;gBACLJ,QAAQI,SAASD,OAAO,CAAC,EAAE;gBAC3BF,SAAS4B;gBACT3B,MAAME,SAASF,IAAI;gBACnBC,SAAS;uBAAIC,SAASD,OAAO;iBAAC;gBAC9BC,UAAU;gBACVC,oBAAoBX,+BAA+ByB;YACrD;QACF;QACA,OAAOvB,iBAAiBiC;IAC1B;IACA,IAAIT,SAASC,MAAM,EAAE,OAAO5B,oBAAoB0B,KAAKC,QAAQ,CAAC,EAAE;IAChE,OAAOvB;AACT"}
@@ -308,10 +308,10 @@ export declare const PLUGIN_DOCS_ANCHORS: {
308
308
  readonly crmViews: readonly ["#the-views-control", "#a-view-is-a-link", "#filters", "#filters-on-the-contacts-list", "#filters-on-the-other-lists", "#columns-and-sort", "#segments-and-views", "#who-sees-what", "#related"];
309
309
  readonly datasets: readonly ["#model-builder", "#typed-documents", "#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
- readonly designedEmails: readonly ["#create-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", "#marketing-mail", "#frequency-cap", "#suppressions", "#add-a-suppression", "#platform-suppressions", "#related"];
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"];
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", "#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", "#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"];
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"];
@@ -704,6 +704,7 @@ export const PLUGIN_DOCS_ANCHORS = {
704
704
  ],
705
705
  designedEmails: [
706
706
  '#create-a-template',
707
+ '#find-a-template',
707
708
  '#duplicate-a-template',
708
709
  '#styling-email-blocks',
709
710
  '#merge-tokens',
@@ -755,6 +756,7 @@ export const PLUGIN_DOCS_ANCHORS = {
755
756
  '#preference-page',
756
757
  '#frequency-opt-down',
757
758
  '#double-opt-in',
759
+ '#consent-group-confirmation',
758
760
  '#marketing-mail',
759
761
  '#frequency-cap',
760
762
  '#suppressions',
@@ -786,6 +788,7 @@ export const PLUGIN_DOCS_ANCHORS = {
786
788
  '#replying-to-a-submission',
787
789
  '#every-sites-inbox-at-once',
788
790
  '#one-forms-own-page',
791
+ '#find-a-form',
789
792
  '#duplicate-a-form',
790
793
  '#switch-forms-off-for-one-site',
791
794
  '#related'