@aglyn/aglyn 1.0.0-beta.173 → 1.0.0-beta.174

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.173",
3
+ "version": "1.0.0-beta.174",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -33,15 +33,15 @@
33
33
  "./package.json": "./package.json"
34
34
  },
35
35
  "dependencies": {
36
- "@aglyn/shared-data-enums": "1.0.0-beta.173",
37
- "@aglyn/shared-data-mdi": "1.0.0-beta.173",
38
- "@aglyn/shared-data-types": "1.0.0-beta.173",
39
- "@aglyn/shared-util-email": "1.0.0-beta.173",
40
- "@aglyn/shared-util-http": "1.0.0-beta.173",
41
- "@aglyn/shared-util-logger": "1.0.0-beta.173",
42
- "@aglyn/shared-util-timestamp": "1.0.0-beta.173",
43
- "@aglyn/shared-util-tools": "1.0.0-beta.173",
44
- "@aglyn/shared-util-vendor": "1.0.0-beta.173",
36
+ "@aglyn/shared-data-enums": "1.0.0-beta.174",
37
+ "@aglyn/shared-data-mdi": "1.0.0-beta.174",
38
+ "@aglyn/shared-data-types": "1.0.0-beta.174",
39
+ "@aglyn/shared-util-email": "1.0.0-beta.174",
40
+ "@aglyn/shared-util-http": "1.0.0-beta.174",
41
+ "@aglyn/shared-util-logger": "1.0.0-beta.174",
42
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.174",
43
+ "@aglyn/shared-util-tools": "1.0.0-beta.174",
44
+ "@aglyn/shared-util-vendor": "1.0.0-beta.174",
45
45
  "@data-driven-forms/react-form-renderer": "^4.2.0",
46
46
  "@msgpack/msgpack": "^3.1.3",
47
47
  "@types/unist": "^3.0.3",
@@ -46,8 +46,9 @@ export declare function normalizeCampaignIds(raw: unknown): string[];
46
46
  * The campaigns a HOST resource — a form, a screen — belongs to.
47
47
  *
48
48
  * Host resources carry the field at the top of the document: they belong to
49
- * one site, which is the same site the campaign belongs to, so there is no
50
- * second holder to keep the edge away from.
49
+ * one site, and whoever may read the resource may read which of the org's
50
+ * campaigns it is filed under, so there is no second holder to keep the edge
51
+ * away from.
51
52
  */
52
53
  export declare function readCampaignIds(resource: Record<string, unknown> | null | undefined): string[];
53
54
  /**
@@ -100,12 +101,13 @@ export declare function campaignMembershipUnchanged(stored: readonly string[], s
100
101
  * detach fails the build rather than shipping a campaign whose removal leaves
101
102
  * that collection pointing at nothing.
102
103
  *
103
- * A lead (`hosts/{hostId}/leads/{personKey}`, AGL-3254) carries the field at
104
- * the top of its document like a form does: a lead is one site's own record,
105
- * captured there and worked there, so there is no second holder to keep the
106
- * edge away from. It joins a campaign by hand — the New lead drawer, the CSV
107
- * import, the list's bulk bar — or by being enrolled in a sequence that is
108
- * in one, and it hands the membership to the contact it converts into.
104
+ * `leads` is here for the site rows the lead migration (AGL-3276) has not
105
+ * reached. A live lead is an org row (`orgs/{orgId}/leads/{personKey}`) that
106
+ * carries the field at the top of its document like a form does, and the
107
+ * deletion walks that collection by name beside this list. A lead joins a
108
+ * campaign by hand — the New lead drawer, the CSV import, the list's bulk
109
+ * bar — or by being enrolled in a sequence that is in one, and it hands the
110
+ * membership to the contact it converts into.
109
111
  *
110
112
  * Contacts are deliberately NOT here. They live on the org
111
113
  * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * ## The edge is a field on the RESOURCE, not a list on the campaign
21
21
  *
22
- * A campaign container lives at `hosts/{hostId}/emailCampaigns/{campaignId}`
22
+ * A campaign container lives at `orgs/{orgId}/emailCampaigns/{campaignId}`
23
23
  * and names nothing outside itself. A send joins one by carrying
24
24
  * `emailCampaignId`; every other member joins the same way, through
25
25
  * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.
@@ -98,8 +98,9 @@
98
98
  * The campaigns a HOST resource — a form, a screen — belongs to.
99
99
  *
100
100
  * Host resources carry the field at the top of the document: they belong to
101
- * one site, which is the same site the campaign belongs to, so there is no
102
- * second holder to keep the edge away from.
101
+ * one site, and whoever may read the resource may read which of the org's
102
+ * campaigns it is filed under, so there is no second holder to keep the edge
103
+ * away from.
103
104
  */ export function readCampaignIds(resource) {
104
105
  return normalizeCampaignIds((resource != null ? resource : {})[CAMPAIGN_MEMBERSHIP_FIELD]);
105
106
  }
@@ -164,12 +165,13 @@
164
165
  * detach fails the build rather than shipping a campaign whose removal leaves
165
166
  * that collection pointing at nothing.
166
167
  *
167
- * A lead (`hosts/{hostId}/leads/{personKey}`, AGL-3254) carries the field at
168
- * the top of its document like a form does: a lead is one site's own record,
169
- * captured there and worked there, so there is no second holder to keep the
170
- * edge away from. It joins a campaign by hand — the New lead drawer, the CSV
171
- * import, the list's bulk bar — or by being enrolled in a sequence that is
172
- * in one, and it hands the membership to the contact it converts into.
168
+ * `leads` is here for the site rows the lead migration (AGL-3276) has not
169
+ * reached. A live lead is an org row (`orgs/{orgId}/leads/{personKey}`) that
170
+ * carries the field at the top of its document like a form does, and the
171
+ * deletion walks that collection by name beside this list. A lead joins a
172
+ * campaign by hand — the New lead drawer, the CSV import, the list's bulk
173
+ * bar — or by being enrolled in a sequence that is in one, and it hands the
174
+ * membership to the contact it converts into.
173
175
  *
174
176
  * Contacts are deliberately NOT here. They live on the org
175
177
  * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/campaign-membership.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 * WHAT ELSE IS IN A CAMPAIGN: the edge between a campaign and the forms,\n * screens and people a push is coordinated across.\n *\n * ## The edge is a field on the RESOURCE, not a list on the campaign\n *\n * A campaign container lives at `hosts/{hostId}/emailCampaigns/{campaignId}`\n * and names nothing outside itself. A send joins one by carrying\n * `emailCampaignId`; every other member joins the same way, through\n * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.\n *\n * Three properties decide it, and all three point the same way:\n *\n * - **Reading is free where it is asked.** A form's own page reads the form\n * document to draw anything at all, so the campaign comes with it. A\n * membership collection under the campaign would make \"which campaign is\n * this in\" a query issued on mount by every resource page, for a fact one\n * field already carries.\n * - **The reduction is already gated this way.** `campaign-manage.ts` clears\n * `emailCampaignId` from every send before it removes a container, so no\n * send is left naming one that is gone. Extending that pass to the\n * membership field is the SAME mechanism; a membership collection would be\n * a second, different deletion story beside it.\n * - **Deleting a MEMBER can leave nothing behind.** The campaign holds no\n * list, so a deleted form takes its own edge with it. This matters most on\n * a contact: an erasure removes the document, and a membership row stored\n * anywhere else would survive it holding the id of a person who asked to\n * be forgotten.\n *\n * ## Plural, because a landing page outlives one campaign\n *\n * The field is an ARRAY. A signup form built for the spring push is the same\n * form the summer push places, and a single-valued field would make the\n * second assignment silently erase the first — a campaign losing a member\n * with nothing on screen to say so. The automation step that has always\n * written this edge (`assignCampaign`) already used `arrayUnion`, so the\n * product has treated it as many-to-many from the start.\n *\n * ## Ids, never names\n *\n * A campaign's name is editable, and every reader here resolves the name from\n * the campaign document at read time. Renaming a campaign therefore moves no\n * membership: the stored value is the container's document id, which nothing\n * rewrites.\n */\n\nimport { contactFacetPath, readContactFacet } from './contacts'\n\n/**\n * The field naming the campaigns a resource belongs to.\n *\n * Not `emailCampaignId`, which is the SEND's field: on a send document the\n * word `campaignId` already means the send's own id — it is what `cid`\n * carries into every unsubscribe footer — so that collection needed a\n * different word. A form, a screen and a contact have no second meaning for\n * it, and the plural says the resource may be in more than one.\n */\nexport const CAMPAIGN_MEMBERSHIP_FIELD = 'campaignIds'\n\n/**\n * How many campaigns one resource may name.\n *\n * A ceiling on the FIELD, not on the product: an array Firestore has to index\n * on every write is not the place to discover that a script has been adding\n * an id a day for a year. Twenty is far past what a person assigns by hand\n * and far short of anything that costs a write.\n */\nexport const CAMPAIGN_MEMBERSHIP_CAP = 20\n\n/**\n * A stored membership array as a clean list of ids.\n *\n * Deduped, trimmed, non-strings dropped, capped. Every reader goes through\n * this because the field is written by three consoles and one automation\n * step, and a surface that trusted the raw value would render `undefined` as\n * a chip the first time one of them wrote a blank.\n */\nexport function normalizeCampaignIds(raw: unknown): string[] {\n if (!Array.isArray(raw)) return []\n const seen: string[] = []\n for (const entry of raw) {\n if (typeof entry !== 'string') continue\n const id = entry.trim()\n if (!id || seen.includes(id)) continue\n seen.push(id)\n if (seen.length >= CAMPAIGN_MEMBERSHIP_CAP) break\n }\n return seen\n}\n\n/**\n * The campaigns a HOST resource — a form, a screen — belongs to.\n *\n * Host resources carry the field at the top of the document: they belong to\n * one site, which is the same site the campaign belongs to, so there is no\n * second holder to keep the edge away from.\n */\nexport function readCampaignIds(\n resource: Record<string, unknown> | null | undefined,\n): string[] {\n return normalizeCampaignIds((resource ?? {})[CAMPAIGN_MEMBERSHIP_FIELD])\n}\n\n/**\n * The dotted path to a contact's membership, inside ONE holder's facet.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one row — and almost nothing on it is legitimately\n * shared. Which campaigns a merchant has filed a person under is that\n * merchant's business record on exactly the footing their notes and tags are,\n * so it lives at `facets.{groupId}.campaignIds` and not at the top of the\n * document, where every other site in an agency's account could read it.\n */\nexport function contactCampaignFieldPath(groupId: string): string {\n return contactFacetPath(groupId, CAMPAIGN_MEMBERSHIP_FIELD)\n}\n\n/** The campaigns ONE holder has filed this contact under. */\nexport function readContactCampaignIds(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string[] {\n return normalizeCampaignIds(readContactFacet(contact, groupId).campaignIds)\n}\n\n/**\n * The membership a save should write, given what is on screen.\n *\n * Pure, and shared by the three consoles that offer the picker, because the\n * decision each of them makes is identical and is not obvious: an EMPTY\n * selection has to be stored as an empty array rather than as a removed\n * field.\n *\n * Removing the field would read the same to every surface here — the readers\n * above answer `[]` either way — and would be wrong for the one reader that\n * is not a surface. `campaign-manage.ts` finds a campaign's members with\n * `array-contains`, which matches on the automatic single-field index; that\n * index has an entry only while the array does, so the two shapes are the\n * same to a reader and different to a writer only in cost. Storing `[]` keeps\n * one shape for \"belongs to no campaign\" across every writer, which is the\n * property the send collection's own detach comment argues for one field\n * over.\n *\n * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.\n */\nexport function campaignMembershipValue(selected: readonly string[]): string[] {\n return normalizeCampaignIds(selected)\n}\n\n/**\n * Whether a document's stored membership already says what a save would.\n *\n * Order-insensitive: a picker hands back its options' order and the stored\n * array is in the order it was written, so comparing them literally would\n * report a change on every open and leave Save permanently enabled.\n */\nexport function campaignMembershipUnchanged(\n stored: readonly string[],\n selected: readonly string[],\n): boolean {\n if (stored.length !== selected.length) return false\n const sortedStored = [...stored].sort()\n const sortedSelected = [...selected].sort()\n return sortedStored.every((id, index) => id === sortedSelected[index])\n}\n\n/**\n * The host subcollections whose documents may name a campaign.\n *\n * The list a campaign's deletion walks, and the list this feature's coverage\n * spec reads — so a collection that grows the picker without growing the\n * detach fails the build rather than shipping a campaign whose removal leaves\n * that collection pointing at nothing.\n *\n * A lead (`hosts/{hostId}/leads/{personKey}`, AGL-3254) carries the field at\n * the top of its document like a form does: a lead is one site's own record,\n * captured there and worked there, so there is no second holder to keep the\n * edge away from. It joins a campaign by hand — the New lead drawer, the CSV\n * import, the list's bulk bar — or by being enrolled in a sequence that is\n * in one, and it hands the membership to the contact it converts into.\n *\n * Contacts are deliberately NOT here. They live on the org\n * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a\n * per-holder facet — so they are detached by their own pass, against a field\n * path that names the group. Nor are a plugin's own members — a sequence and\n * its enrollments live under the org, in collections the core does not name\n * — so a plugin registers a detacher for those\n * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.\n */\nexport const CAMPAIGN_MEMBER_HOST_COLLECTIONS = ['forms', 'screens', 'leads'] as const\n\nexport type CampaignMemberHostCollection =\n (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number]\n"],"names":["contactFacetPath","readContactFacet","CAMPAIGN_MEMBERSHIP_FIELD","CAMPAIGN_MEMBERSHIP_CAP","normalizeCampaignIds","raw","Array","isArray","seen","entry","id","trim","includes","push","length","readCampaignIds","resource","contactCampaignFieldPath","groupId","readContactCampaignIds","contact","campaignIds","campaignMembershipValue","selected","campaignMembershipUnchanged","stored","sortedStored","sort","sortedSelected","every","index","CAMPAIGN_MEMBER_HOST_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SAASA,gBAAgB,EAAEC,gBAAgB,QAAQ,gBAAY;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMC,4BAA4B,cAAa;AAEtD;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,GAAE;AAEzC;;;;;;;CAOC,GACD,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,MAAMG,OAAiB,EAAE;IACzB,KAAK,MAAMC,SAASJ,IAAK;QACvB,IAAI,OAAOI,UAAU,UAAU;QAC/B,MAAMC,KAAKD,MAAME,IAAI;QACrB,IAAI,CAACD,MAAMF,KAAKI,QAAQ,CAACF,KAAK;QAC9BF,KAAKK,IAAI,CAACH;QACV,IAAIF,KAAKM,MAAM,IAAIX,yBAAyB;IAC9C;IACA,OAAOK;AACT;AAEA;;;;;;CAMC,GACD,OAAO,SAASO,gBACdC,QAAoD;IAEpD,OAAOZ,qBAAqB,CAACY,mBAAAA,WAAY,CAAC,EAAE,CAACd,0BAA0B;AACzE;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASe,yBAAyBC,OAAe;IACtD,OAAOlB,iBAAiBkB,SAAShB;AACnC;AAEA,2DAA2D,GAC3D,OAAO,SAASiB,uBACdC,OAAmD,EACnDF,OAAe;IAEf,OAAOd,qBAAqBH,iBAAiBmB,SAASF,SAASG,WAAW;AAC5E;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,wBAAwBC,QAA2B;IACjE,OAAOnB,qBAAqBmB;AAC9B;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,MAAyB,EACzBF,QAA2B;IAE3B,IAAIE,OAAOX,MAAM,KAAKS,SAAST,MAAM,EAAE,OAAO;IAC9C,MAAMY,eAAe;WAAID;KAAO,CAACE,IAAI;IACrC,MAAMC,iBAAiB;WAAIL;KAAS,CAACI,IAAI;IACzC,OAAOD,aAAaG,KAAK,CAAC,CAACnB,IAAIoB,QAAUpB,OAAOkB,cAAc,CAACE,MAAM;AACvE;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,MAAMC,mCAAmC;IAAC;IAAS;IAAW;CAAQ,CAAS"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/campaign-membership.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 * WHAT ELSE IS IN A CAMPAIGN: the edge between a campaign and the forms,\n * screens and people a push is coordinated across.\n *\n * ## The edge is a field on the RESOURCE, not a list on the campaign\n *\n * A campaign container lives at `orgs/{orgId}/emailCampaigns/{campaignId}`\n * and names nothing outside itself. A send joins one by carrying\n * `emailCampaignId`; every other member joins the same way, through\n * {@link CAMPAIGN_MEMBERSHIP_FIELD} on its own document.\n *\n * Three properties decide it, and all three point the same way:\n *\n * - **Reading is free where it is asked.** A form's own page reads the form\n * document to draw anything at all, so the campaign comes with it. A\n * membership collection under the campaign would make \"which campaign is\n * this in\" a query issued on mount by every resource page, for a fact one\n * field already carries.\n * - **The reduction is already gated this way.** `campaign-manage.ts` clears\n * `emailCampaignId` from every send before it removes a container, so no\n * send is left naming one that is gone. Extending that pass to the\n * membership field is the SAME mechanism; a membership collection would be\n * a second, different deletion story beside it.\n * - **Deleting a MEMBER can leave nothing behind.** The campaign holds no\n * list, so a deleted form takes its own edge with it. This matters most on\n * a contact: an erasure removes the document, and a membership row stored\n * anywhere else would survive it holding the id of a person who asked to\n * be forgotten.\n *\n * ## Plural, because a landing page outlives one campaign\n *\n * The field is an ARRAY. A signup form built for the spring push is the same\n * form the summer push places, and a single-valued field would make the\n * second assignment silently erase the first — a campaign losing a member\n * with nothing on screen to say so. The automation step that has always\n * written this edge (`assignCampaign`) already used `arrayUnion`, so the\n * product has treated it as many-to-many from the start.\n *\n * ## Ids, never names\n *\n * A campaign's name is editable, and every reader here resolves the name from\n * the campaign document at read time. Renaming a campaign therefore moves no\n * membership: the stored value is the container's document id, which nothing\n * rewrites.\n */\n\nimport { contactFacetPath, readContactFacet } from './contacts'\n\n/**\n * The field naming the campaigns a resource belongs to.\n *\n * Not `emailCampaignId`, which is the SEND's field: on a send document the\n * word `campaignId` already means the send's own id — it is what `cid`\n * carries into every unsubscribe footer — so that collection needed a\n * different word. A form, a screen and a contact have no second meaning for\n * it, and the plural says the resource may be in more than one.\n */\nexport const CAMPAIGN_MEMBERSHIP_FIELD = 'campaignIds'\n\n/**\n * How many campaigns one resource may name.\n *\n * A ceiling on the FIELD, not on the product: an array Firestore has to index\n * on every write is not the place to discover that a script has been adding\n * an id a day for a year. Twenty is far past what a person assigns by hand\n * and far short of anything that costs a write.\n */\nexport const CAMPAIGN_MEMBERSHIP_CAP = 20\n\n/**\n * A stored membership array as a clean list of ids.\n *\n * Deduped, trimmed, non-strings dropped, capped. Every reader goes through\n * this because the field is written by three consoles and one automation\n * step, and a surface that trusted the raw value would render `undefined` as\n * a chip the first time one of them wrote a blank.\n */\nexport function normalizeCampaignIds(raw: unknown): string[] {\n if (!Array.isArray(raw)) return []\n const seen: string[] = []\n for (const entry of raw) {\n if (typeof entry !== 'string') continue\n const id = entry.trim()\n if (!id || seen.includes(id)) continue\n seen.push(id)\n if (seen.length >= CAMPAIGN_MEMBERSHIP_CAP) break\n }\n return seen\n}\n\n/**\n * The campaigns a HOST resource — a form, a screen — belongs to.\n *\n * Host resources carry the field at the top of the document: they belong to\n * one site, and whoever may read the resource may read which of the org's\n * campaigns it is filed under, so there is no second holder to keep the edge\n * away from.\n */\nexport function readCampaignIds(\n resource: Record<string, unknown> | null | undefined,\n): string[] {\n return normalizeCampaignIds((resource ?? {})[CAMPAIGN_MEMBERSHIP_FIELD])\n}\n\n/**\n * The dotted path to a contact's membership, inside ONE holder's facet.\n *\n * A contact document is shared by every site in the org — one human who\n * touched two sites is one row — and almost nothing on it is legitimately\n * shared. Which campaigns a merchant has filed a person under is that\n * merchant's business record on exactly the footing their notes and tags are,\n * so it lives at `facets.{groupId}.campaignIds` and not at the top of the\n * document, where every other site in an agency's account could read it.\n */\nexport function contactCampaignFieldPath(groupId: string): string {\n return contactFacetPath(groupId, CAMPAIGN_MEMBERSHIP_FIELD)\n}\n\n/** The campaigns ONE holder has filed this contact under. */\nexport function readContactCampaignIds(\n contact: Record<string, unknown> | null | undefined,\n groupId: string,\n): string[] {\n return normalizeCampaignIds(readContactFacet(contact, groupId).campaignIds)\n}\n\n/**\n * The membership a save should write, given what is on screen.\n *\n * Pure, and shared by the three consoles that offer the picker, because the\n * decision each of them makes is identical and is not obvious: an EMPTY\n * selection has to be stored as an empty array rather than as a removed\n * field.\n *\n * Removing the field would read the same to every surface here — the readers\n * above answer `[]` either way — and would be wrong for the one reader that\n * is not a surface. `campaign-manage.ts` finds a campaign's members with\n * `array-contains`, which matches on the automatic single-field index; that\n * index has an entry only while the array does, so the two shapes are the\n * same to a reader and different to a writer only in cost. Storing `[]` keeps\n * one shape for \"belongs to no campaign\" across every writer, which is the\n * property the send collection's own detach comment argues for one field\n * over.\n *\n * @returns the value to store under {@link CAMPAIGN_MEMBERSHIP_FIELD}.\n */\nexport function campaignMembershipValue(selected: readonly string[]): string[] {\n return normalizeCampaignIds(selected)\n}\n\n/**\n * Whether a document's stored membership already says what a save would.\n *\n * Order-insensitive: a picker hands back its options' order and the stored\n * array is in the order it was written, so comparing them literally would\n * report a change on every open and leave Save permanently enabled.\n */\nexport function campaignMembershipUnchanged(\n stored: readonly string[],\n selected: readonly string[],\n): boolean {\n if (stored.length !== selected.length) return false\n const sortedStored = [...stored].sort()\n const sortedSelected = [...selected].sort()\n return sortedStored.every((id, index) => id === sortedSelected[index])\n}\n\n/**\n * The host subcollections whose documents may name a campaign.\n *\n * The list a campaign's deletion walks, and the list this feature's coverage\n * spec reads — so a collection that grows the picker without growing the\n * detach fails the build rather than shipping a campaign whose removal leaves\n * that collection pointing at nothing.\n *\n * `leads` is here for the site rows the lead migration (AGL-3276) has not\n * reached. A live lead is an org row (`orgs/{orgId}/leads/{personKey}`) that\n * carries the field at the top of its document like a form does, and the\n * deletion walks that collection by name beside this list. A lead joins a\n * campaign by hand — the New lead drawer, the CSV import, the list's bulk\n * bar — or by being enrolled in a sequence that is in one, and it hands the\n * membership to the contact it converts into.\n *\n * Contacts are deliberately NOT here. They live on the org\n * (`orgs/{orgId}/contacts`), not the host, and carry the field inside a\n * per-holder facet — so they are detached by their own pass, against a field\n * path that names the group. Nor are a plugin's own members — a sequence and\n * its enrollments live under the org, in collections the core does not name\n * — so a plugin registers a detacher for those\n * (`plugin-manager/plugin-membership-detach.ts`) and the deletion runs it.\n */\nexport const CAMPAIGN_MEMBER_HOST_COLLECTIONS = ['forms', 'screens', 'leads'] as const\n\nexport type CampaignMemberHostCollection =\n (typeof CAMPAIGN_MEMBER_HOST_COLLECTIONS)[number]\n"],"names":["contactFacetPath","readContactFacet","CAMPAIGN_MEMBERSHIP_FIELD","CAMPAIGN_MEMBERSHIP_CAP","normalizeCampaignIds","raw","Array","isArray","seen","entry","id","trim","includes","push","length","readCampaignIds","resource","contactCampaignFieldPath","groupId","readContactCampaignIds","contact","campaignIds","campaignMembershipValue","selected","campaignMembershipUnchanged","stored","sortedStored","sort","sortedSelected","every","index","CAMPAIGN_MEMBER_HOST_COLLECTIONS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SAASA,gBAAgB,EAAEC,gBAAgB,QAAQ,gBAAY;AAE/D;;;;;;;;CAQC,GACD,OAAO,MAAMC,4BAA4B,cAAa;AAEtD;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,GAAE;AAEzC;;;;;;;CAOC,GACD,OAAO,SAASC,qBAAqBC,GAAY;IAC/C,IAAI,CAACC,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,MAAMG,OAAiB,EAAE;IACzB,KAAK,MAAMC,SAASJ,IAAK;QACvB,IAAI,OAAOI,UAAU,UAAU;QAC/B,MAAMC,KAAKD,MAAME,IAAI;QACrB,IAAI,CAACD,MAAMF,KAAKI,QAAQ,CAACF,KAAK;QAC9BF,KAAKK,IAAI,CAACH;QACV,IAAIF,KAAKM,MAAM,IAAIX,yBAAyB;IAC9C;IACA,OAAOK;AACT;AAEA;;;;;;;CAOC,GACD,OAAO,SAASO,gBACdC,QAAoD;IAEpD,OAAOZ,qBAAqB,CAACY,mBAAAA,WAAY,CAAC,EAAE,CAACd,0BAA0B;AACzE;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASe,yBAAyBC,OAAe;IACtD,OAAOlB,iBAAiBkB,SAAShB;AACnC;AAEA,2DAA2D,GAC3D,OAAO,SAASiB,uBACdC,OAAmD,EACnDF,OAAe;IAEf,OAAOd,qBAAqBH,iBAAiBmB,SAASF,SAASG,WAAW;AAC5E;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,wBAAwBC,QAA2B;IACjE,OAAOnB,qBAAqBmB;AAC9B;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,4BACdC,MAAyB,EACzBF,QAA2B;IAE3B,IAAIE,OAAOX,MAAM,KAAKS,SAAST,MAAM,EAAE,OAAO;IAC9C,MAAMY,eAAe;WAAID;KAAO,CAACE,IAAI;IACrC,MAAMC,iBAAiB;WAAIL;KAAS,CAACI,IAAI;IACzC,OAAOD,aAAaG,KAAK,CAAC,CAACnB,IAAIoB,QAAUpB,OAAOkB,cAAc,CAACE,MAAM;AACvE;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,MAAMC,mCAAmC;IAAC;IAAS;IAAW;CAAQ,CAAS"}
@@ -689,9 +689,9 @@ export declare function buildCrmEmailActivity(input: CrmEmailActivityInput): Crm
689
689
  export interface ContactCampaignEmail {
690
690
  /** The provider's message id — distinct per send, and the entry's key. */
691
691
  messageId: string;
692
- /** The site the campaign went out from. */
692
+ /** The site the campaign went out from — the send's `hostId`. */
693
693
  hostId: string;
694
- /** `hosts/{hostId}/campaigns/{campaignId}` — the email, whose report the entry links to. */
694
+ /** `orgs/{orgId}/campaigns/{campaignId}` — the email, whose report the entry links to. */
695
695
  campaignId: string;
696
696
  /**
697
697
  * The email as the team named it in the Emails console — its display name,
@@ -1379,7 +1379,7 @@ export interface CrmLeadFields extends CrmLeadProfile {
1379
1379
  emailState?: EmailState;
1380
1380
  /**
1381
1381
  * The campaigns the lead is filed under (AGL-3254): container ids from
1382
- * `hosts/{hostId}/emailCampaigns`, at the top of the document the way a
1382
+ * `orgs/{orgId}/emailCampaigns`, at the top of the document the way a
1383
1383
  * form carries them (`campaign-membership.ts`), never names. Written by
1384
1384
  * the New lead drawer, the import, the bulk bar and a sequence's enroll;
1385
1385
  * handed to the contact's facet when the lead converts.