@aglyn/plugins-crm 1.0.0-beta.219 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -15
  3. package/src/lib/components/company-import-drawer.d.ts +1 -1
  4. package/src/lib/components/contact-associations-card.js +4 -3
  5. package/src/lib/components/contact-associations-card.js.map +1 -1
  6. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  7. package/src/lib/components/crm-booking-zone.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +2 -1
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/deal-products-card.js +27 -16
  11. package/src/lib/components/deal-products-card.js.map +1 -1
  12. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  13. package/src/lib/components/lead-campaigns-card.js +9 -8
  14. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +4 -0
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-status-chip.js +3 -1
  18. package/src/lib/components/lead-status-chip.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.js +7 -9
  20. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  21. package/src/lib/components/leads-section.js +5 -1
  22. package/src/lib/components/leads-section.js.map +1 -1
  23. package/src/lib/components/new-lead-drawer.js +5 -4
  24. package/src/lib/components/new-lead-drawer.js.map +1 -1
  25. package/src/lib/components/reports/activity-card.js +4 -3
  26. package/src/lib/components/reports/activity-card.js.map +1 -1
  27. package/src/lib/components/reports/closed-deals-card.js +4 -3
  28. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  29. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  30. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  31. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  32. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  33. package/src/lib/components/reports/forecast-card.js +3 -2
  34. package/src/lib/components/reports/forecast-card.js.map +1 -1
  35. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  36. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  37. package/src/lib/components/reports/pipeline-card.js +4 -3
  38. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  39. package/src/lib/components/reports/report-format.d.ts +1 -1
  40. package/src/lib/components/reports/report-format.js.map +1 -1
  41. package/src/lib/components/reports/report-scope.d.ts +1 -1
  42. package/src/lib/components/reports/report-scope.js.map +1 -1
  43. package/src/lib/components/reports/source-conversion-card.js +2 -1
  44. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  45. package/src/lib/components/reports/tasks-card.js +2 -1
  46. package/src/lib/components/reports/tasks-card.js.map +1 -1
  47. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  48. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  49. package/src/lib/components/reports-section.js +4 -4
  50. package/src/lib/components/reports-section.js.map +1 -1
  51. package/src/lib/constants/bundle-common.d.ts +2 -0
  52. package/src/lib/constants/bundle-common.js +1 -0
  53. package/src/lib/constants/bundle-common.js.map +1 -1
  54. package/src/lib/declarations.console-server.d.ts +4 -0
  55. package/src/lib/declarations.console-server.js +12 -0
  56. package/src/lib/declarations.console-server.js.map +1 -1
  57. package/src/lib/declarations.server.d.ts +6 -0
  58. package/src/lib/declarations.server.js +51 -1
  59. package/src/lib/declarations.server.js.map +1 -1
  60. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  61. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  62. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  63. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  64. package/src/lib/model/campaign-filing-activity.js +1 -1
  65. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  66. package/src/lib/model/contact-record.js +1 -1
  67. package/src/lib/model/contact-record.js.map +1 -1
  68. package/src/lib/model/crm-digest.d.ts +45 -3
  69. package/src/lib/model/crm-digest.js +75 -11
  70. package/src/lib/model/crm-digest.js.map +1 -1
  71. package/src/lib/model/crm-inbound.d.ts +162 -0
  72. package/src/lib/model/crm-inbound.js +208 -0
  73. package/src/lib/model/crm-inbound.js.map +1 -0
  74. package/src/lib/model/crm-lead-import.d.ts +1 -1
  75. package/src/lib/model/crm-lead-import.js +4 -4
  76. package/src/lib/model/crm-lead-import.js.map +1 -1
  77. package/src/lib/model/crm-reports.d.ts +467 -0
  78. package/src/lib/model/crm-reports.js +667 -0
  79. package/src/lib/model/crm-reports.js.map +1 -0
  80. package/src/lib/model/lead-filters.d.ts +4 -3
  81. package/src/lib/model/lead-filters.js +7 -5
  82. package/src/lib/model/lead-filters.js.map +1 -1
  83. package/src/lib/plugin.js +36 -0
  84. package/src/lib/plugin.js.map +1 -1
  85. package/src/lib/server/api-v1/crm-leads.js +1 -0
  86. package/src/lib/server/api-v1/crm-leads.js.map +1 -1
  87. package/src/lib/server/api-v1/openapi.js +1 -1
  88. package/src/lib/server/api-v1/openapi.js.map +1 -1
  89. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  90. package/src/lib/server/campaign-filing-activity.js +7 -11
  91. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  92. package/src/lib/server/capture-contact.js +15 -4
  93. package/src/lib/server/capture-contact.js.map +1 -1
  94. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  95. package/src/lib/server/contact-email-engagement.js +146 -0
  96. package/src/lib/server/contact-email-engagement.js.map +1 -0
  97. package/src/lib/server/contact-update.js +3 -3
  98. package/src/lib/server/contact-update.js.map +1 -1
  99. package/src/lib/server/container-detach.d.ts +31 -0
  100. package/src/lib/server/container-detach.js +115 -0
  101. package/src/lib/server/container-detach.js.map +1 -0
  102. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  103. package/src/lib/server/crm-inbound-email.js +259 -0
  104. package/src/lib/server/crm-inbound-email.js.map +1 -0
  105. package/src/lib/server/crm-records-meter.d.ts +50 -0
  106. package/src/lib/server/crm-records-meter.js +126 -0
  107. package/src/lib/server/crm-records-meter.js.map +1 -0
  108. package/src/lib/server/daily-digest-route.d.ts +2 -8
  109. package/src/lib/server/daily-digest-route.js +15 -25
  110. package/src/lib/server/daily-digest-route.js.map +1 -1
  111. package/src/lib/server/inbound-address.js +5 -3
  112. package/src/lib/server/inbound-address.js.map +1 -1
  113. package/src/lib/server/inbound-route.js +7 -4
  114. package/src/lib/server/inbound-route.js.map +1 -1
  115. package/src/lib/server/lead-campaign-carry.js +3 -3
  116. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  117. package/src/lib/server/lead-create.d.ts +1 -1
  118. package/src/lib/server/lead-create.js +10 -15
  119. package/src/lib/server/lead-create.js.map +1 -1
  120. package/src/lib/server/lead-nurturing.d.ts +23 -0
  121. package/src/lib/server/lead-nurturing.js +82 -0
  122. package/src/lib/server/lead-nurturing.js.map +1 -0
  123. package/src/lib/server/leads-import.js +7 -7
  124. package/src/lib/server/leads-import.js.map +1 -1
  125. package/src/lib/server/record-email-state.js +23 -0
  126. package/src/lib/server/record-email-state.js.map +1 -1
  127. package/src/lib/server/record-timeline.d.ts +12 -0
  128. package/src/lib/server/record-timeline.js +127 -15
  129. package/src/lib/server/record-timeline.js.map +1 -1
  130. package/src/lib/server/task-reminders-route.js +4 -2
  131. package/src/lib/server/task-reminders-route.js.map +1 -1
  132. package/src/lib/server/task-routes.d.ts +2 -0
  133. package/src/lib/server/task-routes.js +22 -3
  134. package/src/lib/server/task-routes.js.map +1 -1
  135. package/src/lib/usage-axes.js +8 -1
  136. package/src/lib/usage-axes.js.map +1 -1
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
19
19
  import { CRM_COLLECTIONS, crmActivityLogHasRoom, crmScopeTokens } from "@aglyn/aglyn/app-utils/crm";
20
20
  import { countCrmActivitiesForRecord } from "@aglyn/tenant-data-admin/server/crm-records";
21
+ import { readOrgContainers } from "@aglyn/tenant-data-admin/server/org-containers";
21
22
  import { createHash } from "crypto";
22
23
  import { FieldValue } from "firebase-admin/firestore";
23
24
  import { BUNDLE_ID } from "../constants/bundle-common.js";
@@ -27,7 +28,7 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
27
28
  }
28
29
  /** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */ const ALREADY_EXISTS = 6;
29
30
  /**
30
- * The names of an org's campaigns (`orgs/{orgId}/emailCampaigns`), by id,
31
+ * The names of an org's campaigns — the `campaign` container kind — by id,
31
32
  * for the entries that name them. A container that is gone answers its id,
32
33
  * so the entry still says which.
33
34
  */ export async function orgCampaignRefs(firestore, orgId, campaignIds) {
@@ -36,16 +37,11 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
36
37
  id,
37
38
  name: id
38
39
  }));
39
- const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns');
40
- const found = await firestore.getAll(...campaignIds.map((id)=>containers.doc(id)));
41
- return campaignIds.map((id, index)=>{
42
- var _ref;
43
- var _found_index;
44
- return {
45
- id,
46
- name: String((_ref = (_found_index = found[index]) == null ? void 0 : _found_index.get('name')) != null ? _ref : '').trim() || id
47
- };
48
- });
40
+ const found = await readOrgContainers(firestore, 'campaign', orgId, campaignIds);
41
+ return found.map((container)=>({
42
+ id: container.id,
43
+ name: container.name || container.id
44
+ }));
49
45
  }
50
46
  /** Files one entry per campaign; answers how many were written. */ export async function fileCampaignFilingActivities(firestore, input) {
51
47
  if (!input.campaigns.length) return 0;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/campaign-filing-activity.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\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of an org's campaigns (`orgs/{orgId}/emailCampaigns`), by id,\n * for the entries that name them. A container that is gone answers its id,\n * so the entry still says which.\n */\nexport async function orgCampaignRefs(\n firestore: Firestore,\n orgId: string,\n campaignIds: readonly string[],\n): Promise<CampaignFilingRef[]> {\n if (!campaignIds.length) return []\n if (!orgId) return campaignIds.map((id) => ({ id, name: id }))\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...campaignIds.map((id) => containers.doc(id)))\n return campaignIds.map((id, index) => ({\n id,\n name: String(found[index]?.get('name') ?? '').trim() || id,\n }))\n}\n\n/** Files one entry per campaign; answers how many were written. */\nexport async function fileCampaignFilingActivities(\n firestore: Firestore,\n input: CampaignFilingActivitiesInput,\n): Promise<number> {\n if (!input.campaigns.length) return 0\n try {\n const orgRef = firestore.collection('orgs').doc(input.orgId)\n const org =\n input.org ?? ((await orgRef.get()).data() as Record<string, unknown> | undefined) ?? null\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, input.hostId))\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {\n console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link)\n return 0\n }\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n let written = 0\n for (const campaign of input.campaigns) {\n const entry = {\n ...buildCampaignFilingActivity({\n action: input.action,\n campaign,\n link: input.link,\n hostId: input.hostId,\n visibleTo,\n atMs: input.atMs,\n byUid: input.byUid,\n byName: input.byName ?? null,\n }),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (input.dedupeKey) {\n try {\n await activities.doc(campaignFilingActivityId(`${input.dedupeKey}:${campaign.id}`)).create(entry)\n written += 1\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n }\n } else {\n // A member's act, new each time, named by the platform's own id.\n await activities.doc(createResourceUid()).create(entry)\n written += 1\n }\n }\n return written\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', input.link, error)\n return 0\n }\n}\n"],"names":["consentGroupForHost","createResourceUid","CRM_COLLECTIONS","crmActivityLogHasRoom","crmScopeTokens","countCrmActivitiesForRecord","createHash","FieldValue","BUNDLE_ID","buildCampaignFilingActivity","campaignFilingActivityId","key","update","digest","slice","ALREADY_EXISTS","orgCampaignRefs","firestore","orgId","campaignIds","length","map","id","name","containers","collection","doc","found","getAll","index","String","get","trim","fileCampaignFilingActivities","input","campaigns","orgRef","org","data","visibleTo","hostId","link","console","warn","activities","written","campaign","entry","action","atMs","byUid","byName","createdAt","serverTimestamp","updatedAt","dedupeKey","create","error","code"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/campaign-filing-activity.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\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { readOrgContainers } from '@aglyn/tenant-data-admin/server/org-containers'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of an org's campaigns — the `campaign` container kind — by id,\n * for the entries that name them. A container that is gone answers its id,\n * so the entry still says which.\n */\nexport async function orgCampaignRefs(\n firestore: Firestore,\n orgId: string,\n campaignIds: readonly string[],\n): Promise<CampaignFilingRef[]> {\n if (!campaignIds.length) return []\n if (!orgId) return campaignIds.map((id) => ({ id, name: id }))\n const found = await readOrgContainers(firestore, 'campaign', orgId, campaignIds)\n return found.map((container) => ({ id: container.id, name: container.name || container.id }))\n}\n\n/** Files one entry per campaign; answers how many were written. */\nexport async function fileCampaignFilingActivities(\n firestore: Firestore,\n input: CampaignFilingActivitiesInput,\n): Promise<number> {\n if (!input.campaigns.length) return 0\n try {\n const orgRef = firestore.collection('orgs').doc(input.orgId)\n const org =\n input.org ?? ((await orgRef.get()).data() as Record<string, unknown> | undefined) ?? null\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, input.hostId))\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {\n console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link)\n return 0\n }\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n let written = 0\n for (const campaign of input.campaigns) {\n const entry = {\n ...buildCampaignFilingActivity({\n action: input.action,\n campaign,\n link: input.link,\n hostId: input.hostId,\n visibleTo,\n atMs: input.atMs,\n byUid: input.byUid,\n byName: input.byName ?? null,\n }),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (input.dedupeKey) {\n try {\n await activities.doc(campaignFilingActivityId(`${input.dedupeKey}:${campaign.id}`)).create(entry)\n written += 1\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n }\n } else {\n // A member's act, new each time, named by the platform's own id.\n await activities.doc(createResourceUid()).create(entry)\n written += 1\n }\n }\n return written\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', input.link, error)\n return 0\n }\n}\n"],"names":["consentGroupForHost","createResourceUid","CRM_COLLECTIONS","crmActivityLogHasRoom","crmScopeTokens","countCrmActivitiesForRecord","readOrgContainers","createHash","FieldValue","BUNDLE_ID","buildCampaignFilingActivity","campaignFilingActivityId","key","update","digest","slice","ALREADY_EXISTS","orgCampaignRefs","firestore","orgId","campaignIds","length","map","id","name","found","container","fileCampaignFilingActivities","input","campaigns","orgRef","collection","doc","org","get","data","visibleTo","hostId","link","console","warn","activities","written","campaign","entry","action","atMs","byUid","byName","createdAt","serverTimestamp","updatedAt","dedupeKey","create","error","code"],"mappings":"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"}
@@ -189,7 +189,7 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
189
189
  }
190
190
  /** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
191
191
  try {
192
- var _request_tags, _request_campaignIds;
192
+ var _request_tags;
193
193
  const verdict = await captureHostContact(_extends({}, request.actor ? {
194
194
  actor: request.actor
195
195
  } : {}, {
@@ -210,9 +210,7 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
210
210
  marketingConsent: request.marketingConsent
211
211
  }, disclosureOf(request), ((_request_tags = request.tags) == null ? void 0 : _request_tags.length) ? {
212
212
  tags: request.tags
213
- } : {}, ((_request_campaignIds = request.campaignIds) == null ? void 0 : _request_campaignIds.length) ? {
214
- campaignIds: request.campaignIds
215
- } : {}, request.purchaseCents === undefined ? {} : {
213
+ } : {}, campaignsFiledUnder(request), request.purchaseCents === undefined ? {} : {
216
214
  purchaseCents: request.purchaseCents
217
215
  }, request.purchaseCurrency ? {
218
216
  purchaseCurrency: request.purchaseCurrency
@@ -286,6 +284,19 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
286
284
  path
287
285
  } : {});
288
286
  }
287
+ /**
288
+ * The campaigns the capture surface is filed under, as the contact writer
289
+ * records them. A capture names what its surface is filed under by container
290
+ * kind; the CRM keeps the `campaign` kind on the person's site facet, and a
291
+ * kind it does not keep is the capture's to name and the CRM's to ignore.
292
+ */ function campaignsFiledUnder(request) {
293
+ var _ref;
294
+ var _request_containers;
295
+ const ids = (_ref = (_request_containers = request.containers) == null ? void 0 : _request_containers['campaign']) != null ? _ref : [];
296
+ return ids.length ? {
297
+ campaignIds: ids
298
+ } : {};
299
+ }
289
300
  /**
290
301
  * WHERE THE VISITOR CAME FROM, off the same bag.
291
302
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n const cause = contactCaptureActor(\n request.interaction.source,\n email,\n request.actor,\n )\n await emitHostEvent(\n request.hostId,\n 'lead',\n {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n },\n cause ? { actor: cause } : {},\n )\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n ...(request.actor ? { actor: request.actor } : {}),\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","contactCaptureActor","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","cause","actor","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAMD,SACEA,qBAAqB,QAEhB,kCAAiC;AACxC,SAASC,cAAc,QAAQ,+BAA8B;AAC7D,SAEEC,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,kBAAkB,QAAQ,6CAA4C;AAC/E,SAASC,mBAAmB,QAAQ,sCAAqC;AACzE,SAASC,aAAa,QAAQ,wCAAuC;AACrE,SACEC,kBAAkB,EAClBC,kBAAkB,EAClBC,aAAa,EACbC,wBAAwB,EACxBC,eAAe,QAEV,2BAA0B;AAEjC,SAASC,0BAA0B,QAAQ,yBAAqB;AAEhE;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD,OAAO,eAAeC,qBACpBC,OAAoC;QAEpBA;IAAhB,MAAMC,WAAUD,mBAAAA,QAAQC,OAAO,YAAfD,mBAAmB;IACnC,qEAAqE;IACrE,0DAA0D;IAC1D,IAAI,CAACZ,sBAAsBY,QAAQE,QAAQ,CAACC,KAAK,GAAG,OAAOC;IAC3D,IAAI;QACF,IAAIH,YAAY,QAAQ;YACtB,IAAI,CAAE,MAAMI,cAAcL,UAAW,OAAO,MAAMM,SAASN;QAC7D,OAAO,IAAIC,YAAY,SAAS;YAC9B,IAAI,MAAMM,YAAYP,UAAU,OAAO,MAAMM,SAASN;QACxD;QACA,MAAMQ,UAAU,MAAMC,iBAAiBT;QACvC,IAAIQ,QAAQE,EAAE,IAAIF,QAAQG,MAAM,KAAK,aAAaV,YAAY,gBAAgB;YAC5E,MAAMH,2BAA2B;gBAC/Bc,QAAQZ,QAAQY,MAAM;gBACtBT,OAAOH,QAAQE,QAAQ,CAACC,KAAK;gBAC7BU,WAAWL,QAAQK,SAAS;gBAC5BC,IAAId,QAAQe,WAAW,CAACC,MAAM,KAAK,UAAU,aAAa;YAC5D;QACF;QACA,OAAOR;IACT,EAAE,OAAOS,OAAO;QACdC,QAAQD,KAAK,CAAC,8BAA8BA;QAC5C,OAAO;YACLP,IAAI;YACJS,QAAQ;YACRF,OAAO;QACT;IACF;AACF;AAEA,gEAAgE,GAChE,SAASb;IACP,OAAO;QAAEM,IAAI;QAAOS,QAAQ;QAAiBF,OAAOG,YAAY;IAAiB;AACnF;AAEA;;;;;;CAMC,GACD,eAAef,cAAcL,OAAoC;IAC/D,MAAMqB,WAAW,MAAMzB,yBAAyBI,QAAQY,MAAM,EAAE;IAChE,OAAO,AAAC,MAAMlB,mBAAmB2B,UAAUrB,QAAQE,QAAQ,CAACC,KAAK,MAAO;AAC1E;AAEA,wFAAwF,GACxF,eAAeI,YAAYP,OAAoC;QAU/CsB;IATd,MAAMC,MAAMlC,UAAUW,QAAQE,QAAQ,CAACC,KAAK;IAC5C;;;;;GAKC,GACD,MAAMmB,WAAW,MAAMzB,gBAAgBG,QAAQY,MAAM,EAAEW;IACvD,IAAI,CAACD,UAAU,OAAO;IACtB,MAAME,QAAQF,iBAAAA,SAASG,IAAI,cAAbH,iBAAmB,CAAC;IAClC,OAAO,CAACE,KAAKE,kBAAkB,IAAIvC,cAAcqC;AACnD;AAEA;;;;;CAKC,GACD,eAAelB,SAASN,OAAoC;IAC1D,MAAMG,QAAQf,sBAAsBY,QAAQE,QAAQ,CAACC,KAAK;IAC1D,MAAMoB,MAAMlC,UAAUc;IACtB,MAAMwB,UAAUhC,cAAciC,GAAG,GAAGC,SAAS,GAAGC,UAAU,CAAC,SAASC,GAAG,CAAC/B,QAAQY,MAAM;IACtF,0EAA0E;IAC1E,8EAA8E;IAC9E,MAAMoB,UAAU,CAAE,MAAMnC,gBAAgBG,QAAQY,MAAM,EAAEW;IACxD,MAAM,EAAEU,MAAM,EAAE,GAAGC,aAAalC,QAAQmC,MAAM;IAC9C,MAAMnB,SACJhB,QAAQe,WAAW,CAACC,MAAM,KAAK,UAAUiB,SACrC/C,eAAe+C,UACfjC,QAAQe,WAAW,CAACC,MAAM;IAChC,MAAMoB,UAAU,MAAM3C,mBAAmB;QACvCkC;QACAf,QAAQZ,QAAQY,MAAM;QACtBY,MAAM;YACJrB;WACIH,QAAQE,QAAQ,CAACmC,IAAI,GAAG;YAAEA,MAAMrC,QAAQE,QAAQ,CAACmC,IAAI;QAAC,IAAI,CAAC;YAC/DrB;WACIhB,QAAQsC,gBAAgB,GAAG;YAAEA,kBAAkB;QAAK,IAAI,CAAC,GAC1DC,aAAavC;OAEdwC,gBAAgBxC,QAAQmC,MAAM,EAAEM,aAAa,GAC7C;QAAEC,OAAOF,gBAAgBxC,QAAQmC,MAAM,EAAEM,aAAa;IAAC,IACvD,CAAC;IAEP,IAAI,CAACL,QAAQO,MAAM,EAAE;QACnB,OAAO;YACLjC,IAAI;YACJS,QAAQ;YACRF,OAAO;QACT;IACF;IACA,IAAIe,SAAS;QACX,MAAMY,QAAQrD,oBACZS,QAAQe,WAAW,CAACC,MAAM,EAC1Bb,OACAH,QAAQ6C,KAAK;QAEf,MAAMrD,cACJQ,QAAQY,MAAM,EACd,QACA;YACET;YACAa;YACA8B,QAAQvB;WACJvB,QAAQE,QAAQ,CAACmC,IAAI,GAAG;YAAEA,MAAMrC,QAAQE,QAAQ,CAACmC,IAAI;QAAC,IAAI,CAAC,IAEjEO,QAAQ;YAAEC,OAAOD;QAAM,IAAI,CAAC;IAEhC;IACA,OAAO;QAAElC,IAAI;QAAMC,QAAQ;QAAQmC,QAAQvB;QAAKS;QAASe,aAAaX,QAAQW,WAAW;IAAC;AAC5F;AAEA,6EAA6E,GAC7E,eAAetC,iBACbT,OAAoC;IAEpC,IAAI;YAsBIA,eACAA;QAtBN,MAAMQ,UAAU,MAAMlB,mBAAmB,aACnCU,QAAQ6C,KAAK,GAAG;YAAEA,OAAO7C,QAAQ6C,KAAK;QAAC,IAAI,CAAC;YAChDjC,QAAQZ,QAAQY,MAAM;YACtBT,OAAOH,QAAQE,QAAQ,CAACC,KAAK;WACzBH,QAAQE,QAAQ,CAACmC,IAAI,GAAG;YAAEA,MAAMrC,QAAQE,QAAQ,CAACmC,IAAI;QAAC,IAAI,CAAC;YAC/DrB,QAAQgC,gBAAgBhD,QAAQe,WAAW,CAACC,MAAM;YAClDD,aAAa,aACPf,QAAQe,WAAW,CAACkC,IAAI,KAAKC,YAC7B,CAAC,IACD;gBAAED,MAAMjD,QAAQe,WAAW,CAACkC,IAAI;YAAC,GACjCjD,QAAQe,WAAW,CAACoC,KAAK,GAAG;gBAAEA,OAAOnD,QAAQe,WAAW,CAACoC,KAAK;YAAC,IAAI,CAAC,GACpEnD,QAAQe,WAAW,CAACqC,OAAO,GAC3B;gBAAEA,SAASpD,QAAQe,WAAW,CAACqC,OAAO;YAAC,IACvC,CAAC,GACFlB,aAAalC,QAAQmC,MAAM;WAE7BK,gBAAgBxC,QAAQmC,MAAM,GAC7BnC,QAAQsC,gBAAgB,KAAKY,YAC7B,CAAC,IACD;YAAEZ,kBAAkBtC,QAAQsC,gBAAgB;QAAC,GAC9CC,aAAavC,UACZA,EAAAA,gBAAAA,QAAQqD,IAAI,qBAAZrD,cAAcsD,MAAM,IAAG;YAAED,MAAMrD,QAAQqD,IAAI;QAAC,IAAI,CAAC,GACjDrD,EAAAA,uBAAAA,QAAQuD,WAAW,qBAAnBvD,qBAAqBsD,MAAM,IAAG;YAAEC,aAAavD,QAAQuD,WAAW;QAAC,IAAI,CAAC,GACtEvD,QAAQwD,aAAa,KAAKN,YAC1B,CAAC,IACD;YAAEM,eAAexD,QAAQwD,aAAa;QAAC,GACvCxD,QAAQyD,gBAAgB,GACxB;YAAEA,kBAAkBzD,QAAQyD,gBAAgB;QAAC,IAC7C,CAAC,GACDzD,QAAQ0D,cAAc,GACtB;YAAEC,uBAAuB3D,QAAQ0D,cAAc;QAAU,IACzD,CAAC,GACD1D,QAAQ4D,OAAO,GAAG;YAAEC,OAAO7D,QAAQ4D,OAAO;QAAU,IAAI,CAAC;QAE/D,IAAI,aAAapD,SAAS;YACxB,OAAO;gBAAEE,IAAI;gBAAOS,QAAQX,QAAQsD,OAAO;gBAAE7C,OAAOG,YAAYZ,QAAQsD,OAAO;YAAE;QACnF;QACA,OAAO;YACLpD,IAAI;YACJC,QAAQ;YACRE,WAAWL,QAAQK,SAAS;YAC5BmB,SAASxB,QAAQwB,OAAO;QAC1B;IACF,EAAE,OAAOf,OAAO;QACd;;;;;KAKC,GACDC,QAAQD,KAAK,CAAC,8BAA8BA;QAC5C,OAAO;YACLP,IAAI;YACJS,QAAQ;YACRF,OAAO;QACT;IACF;AACF;AAEA;;;;;;;;CAQC,GACD,SAASsB,aACPvC,OAAoC;IAEpC,MAAMuB,MAAMvB,QAAQ+D,qBAAqB;IACzC,OAAO,OAAOxC,QAAQ,YAAYA,MAAM;QAAEwC,uBAAuBxC;IAAI,IAAI,CAAC;AAC5E;AAEA;;;;;;;;;;;;;;CAcC,GACD,SAASW,aACPC,MAAqD;IAErD,MAAM6B,OAAO,CAACC,QACZ,OAAOA,UAAU,YAAYA,MAAMC,IAAI,KAAKD,QAAQf;IACtD,MAAMjB,SAAS+B,KAAK7B,0BAAAA,MAAQ,CAAC,SAAS;IACtC,MAAMgC,OAAOH,KAAK7B,0BAAAA,MAAQ,CAAC,OAAO;IAClC,OAAO,aACDF,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvBkC,OAAO;QAAEA;IAAK,IAAI,CAAC;AAE3B;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,SAAS3B,gBACPL,MAAqD;IAErD,MAAMO,QAAQP,0BAAAA,MAAQ,CAAC,gBAAgB;IACvC,OAAOO,QAAQ;QAAED,eAAeC;IAA+B,IAAI,CAAC;AACtE;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASM,gBAAgBhC,MAAc;IAC5C,MAAMoD,OAAOC,OAAOrD,iBAAAA,SAAU,IAAIkD,IAAI;IACtC,IAAI,CAACI,OAAOC,MAAM,CAACtF,uBAAuBmF,OAAO;QAC/C,sEAAsE;QACtE,2DAA2D;QAC3DlD,QAAQsD,IAAI,CACV,CAAC,6BAA6B,EAAEJ,KAAK,kCAAkC,CAAC,GACtE;IAEN;IACA,OAAOA;AACT;AAEA,oEAAoE,GACpE,SAAShD,YAAYD,MAAqD;IACxE,OAAQA;QACN,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT;YACE,OAAO;IACX;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.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\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n const cause = contactCaptureActor(\n request.interaction.source,\n email,\n request.actor,\n )\n await emitHostEvent(\n request.hostId,\n 'lead',\n {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n },\n cause ? { actor: cause } : {},\n )\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n ...(request.actor ? { actor: request.actor } : {}),\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...campaignsFiledUnder(request),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * The campaigns the capture surface is filed under, as the contact writer\n * records them. A capture names what its surface is filed under by container\n * kind; the CRM keeps the `campaign` kind on the person's site facet, and a\n * kind it does not keep is the capture's to name and the CRM's to ignore.\n */\nfunction campaignsFiledUnder(\n request: PluginContactCaptureRequest,\n): Pick<UpsertHostContactOptions, 'campaignIds'> {\n const ids = request.containers?.['campaign'] ?? []\n return ids.length ? { campaignIds: ids } : {}\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","contactCaptureActor","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","cause","actor","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignsFiledUnder","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","ids","containers","campaignIds","word","String","Object","hasOwn","warn"],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginRecordEmailEvent } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state';
18
+ /** The facet field the stamp lives under. */
19
+ export declare const CONTACT_EMAIL_ENGAGEMENT_FIELD = "lastEmailEngagementAtMs";
20
+ /**
21
+ * Stamps the sending site's contact facet for every person these events
22
+ * say engaged for the first time with a message — the CRM's answer on the
23
+ * core's record email-state seam (`stampRecordEmailEngagement`).
24
+ *
25
+ * @param hostId the site the campaign went out from — the `hostId` tag the
26
+ * send stamped, which is the only tenant identity a delivery event carries.
27
+ * @returns how many contact documents were written.
28
+ */
29
+ export declare function recordContactEmailEngagement(args: {
30
+ hostId: string;
31
+ events: readonly PluginRecordEmailEvent[];
32
+ firestore?: any;
33
+ }): Promise<number>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * THE CONTACT'S OWN ENGAGEMENT STAMP (AGL-2616):
18
+ * `facets.{groupId}.lastEmailEngagementAtMs` on `orgs/{orgId}/contacts`.
19
+ *
20
+ * ## What it answers
21
+ *
22
+ * "When did this person last read one of OUR campaigns?" — on the record
23
+ * page as "Last engaged", as a column on the list, and as the audience rule
24
+ * `engagedWithinDays`. The per-person rollup on `emailDeliveries/{key}`
25
+ * already answers "when did this address last read anything from anybody",
26
+ * which is the right question for a sunset that refuses to mail cold
27
+ * addresses and the wrong one for a re-engagement audience: it moves when
28
+ * the person opens a receipt, an invite, or a sibling business's newsletter
29
+ * on the same shared row. This stamp moves only when they open or click a
30
+ * campaign this holder's own sites sent.
31
+ *
32
+ * ## What one webhook event costs
33
+ *
34
+ * The same bound the rollup keeps: only an event that is the FIRST of its
35
+ * type for its message reaches here, which `recordEmailDeliveryEvent`'s
36
+ * transaction already decided. A reader opening one newsletter six times is
37
+ * one transaction, and a replayed event finds its type already recorded and
38
+ * contributes nothing. Inside that bound the cost per person is one org
39
+ * resolution and one group resolution per batch, then one keyed query and
40
+ * at most one update per person.
41
+ *
42
+ * ## Forward-only, in a transaction
43
+ *
44
+ * Provider events are unordered and at-least-once, so an old instant
45
+ * arriving late must not overwrite a fresh stamp — the same reasoning
46
+ * `recordPersonEngagement` gives. The query for the contact rides inside
47
+ * the transaction so the compare-and-write is atomic against a second event
48
+ * for the same person landing in another instance.
49
+ *
50
+ * ## Only a contact this site may see, and only this holder's facet
51
+ *
52
+ * The contact row is shared by every site in the org. The stamp is written
53
+ * under the sending site's consent group, never at the top of the document,
54
+ * and only onto a row whose `visibleTo` admits the site — a person another
55
+ * holder captured and this site never met is not this site's contact, and a
56
+ * write onto their row would mint a facet for a holder that does not hold
57
+ * them. `updatedAt` is left alone: an open is something the person did, not
58
+ * an edit the team made, and a list sorted on recency must not reshuffle on
59
+ * every mailbox prefetch.
60
+ *
61
+ * ## Never throws
62
+ *
63
+ * Best-effort for the reason everything on the webhook path is: a stamp that
64
+ * failed loses a fact a page can live without, and a stamp that threw would
65
+ * lose the provider's acknowledgement and teach it to retry the whole event.
66
+ */ import { contactFacetPath, normalizeContactEmail, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
67
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
68
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
69
+ import { consentGroupForSite, orgDataCollectionForHost } from "@aglyn/tenant-data-admin/server/organizations";
70
+ /** The facet field the stamp lives under. */ export const CONTACT_EMAIL_ENGAGEMENT_FIELD = 'lastEmailEngagementAtMs';
71
+ /** The event types that count as a person engaging with a campaign. */ const ENGAGEMENT_TYPES = new Set([
72
+ 'opened',
73
+ 'clicked'
74
+ ]);
75
+ /**
76
+ * Stamps the sending site's contact facet for every person these events
77
+ * say engaged for the first time with a message — the CRM's answer on the
78
+ * core's record email-state seam (`stampRecordEmailEngagement`).
79
+ *
80
+ * @param hostId the site the campaign went out from — the `hostId` tag the
81
+ * send stamped, which is the only tenant identity a delivery event carries.
82
+ * @returns how many contact documents were written.
83
+ */ export async function recordContactEmailEngagement(args) {
84
+ var _args_hostId, _args_firestore;
85
+ const hostId = String((_args_hostId = args.hostId) != null ? _args_hostId : '');
86
+ if (!hostId) return 0;
87
+ /** Address → the newest engagement instant in this batch. */ const byEmail = new Map();
88
+ for (const outcome of args.events){
89
+ var _byEmail_get;
90
+ if (!outcome.firstOfType) continue;
91
+ if (!ENGAGEMENT_TYPES.has(outcome.type)) continue;
92
+ const email = normalizeContactEmail(outcome.to);
93
+ const at = Number(outcome.at);
94
+ if (!email || !Number.isFinite(at) || at <= 0) continue;
95
+ byEmail.set(email, Math.max((_byEmail_get = byEmail.get(email)) != null ? _byEmail_get : 0, at));
96
+ }
97
+ if (!byEmail.size) return 0;
98
+ let contactsRef;
99
+ let groupId;
100
+ try {
101
+ contactsRef = await orgDataCollectionForHost(hostId, 'contacts');
102
+ groupId = (await consentGroupForSite(hostId)).groupId;
103
+ } catch (error) {
104
+ console.error('[contact-email-engagement] site could not be resolved', hostId, error);
105
+ return 0;
106
+ }
107
+ const db = (_args_firestore = args.firestore) != null ? _args_firestore : firebaseAdmin.app().firestore();
108
+ const field = contactFacetPath(groupId, CONTACT_EMAIL_ENGAGEMENT_FIELD);
109
+ let written = 0;
110
+ for (const [email, at] of byEmail){
111
+ try {
112
+ await db.runTransaction(async (transaction)=>{
113
+ var _snapshot_data, _readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD;
114
+ /*
115
+ * The same unscoped lookup the capture door makes — one human is one
116
+ * row whichever site met them, and through the address index
117
+ * (AGL-2633) the row is found under an address a merge folded into
118
+ * it too — followed by the scope check the capture door's
119
+ * `visibleTo` write is the source of. Read THROUGH the transaction:
120
+ * the stamp below is a compare-and-set against the instant this
121
+ * read saw.
122
+ */ const snapshot = await findContactByEmail(contactsRef, email, {
123
+ transaction
124
+ });
125
+ if (!snapshot) return;
126
+ const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
127
+ if (!visibleToHost(data['visibleTo'], hostId)) {
128
+ return;
129
+ }
130
+ const stored = Number((_readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD = readContactFacet(data, groupId)[CONTACT_EMAIL_ENGAGEMENT_FIELD]) != null ? _readContactFacet_CONTACT_EMAIL_ENGAGEMENT_FIELD : 0);
131
+ // Nothing moved forward, so nothing is written — the out-of-order
132
+ // and the replayed event are the ordinary cases this skips.
133
+ if (Number.isFinite(stored) && stored >= at) return;
134
+ transaction.update(snapshot.ref, {
135
+ [field]: at
136
+ });
137
+ written += 1;
138
+ });
139
+ } catch (error) {
140
+ console.error('[contact-email-engagement] stamp failed', hostId, error);
141
+ }
142
+ }
143
+ return written;
144
+ }
145
+
146
+ //# sourceMappingURL=contact-email-engagement.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-email-engagement.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 * THE CONTACT'S OWN ENGAGEMENT STAMP (AGL-2616):\n * `facets.{groupId}.lastEmailEngagementAtMs` on `orgs/{orgId}/contacts`.\n *\n * ## What it answers\n *\n * \"When did this person last read one of OUR campaigns?\" — on the record\n * page as \"Last engaged\", as a column on the list, and as the audience rule\n * `engagedWithinDays`. The per-person rollup on `emailDeliveries/{key}`\n * already answers \"when did this address last read anything from anybody\",\n * which is the right question for a sunset that refuses to mail cold\n * addresses and the wrong one for a re-engagement audience: it moves when\n * the person opens a receipt, an invite, or a sibling business's newsletter\n * on the same shared row. This stamp moves only when they open or click a\n * campaign this holder's own sites sent.\n *\n * ## What one webhook event costs\n *\n * The same bound the rollup keeps: only an event that is the FIRST of its\n * type for its message reaches here, which `recordEmailDeliveryEvent`'s\n * transaction already decided. A reader opening one newsletter six times is\n * one transaction, and a replayed event finds its type already recorded and\n * contributes nothing. Inside that bound the cost per person is one org\n * resolution and one group resolution per batch, then one keyed query and\n * at most one update per person.\n *\n * ## Forward-only, in a transaction\n *\n * Provider events are unordered and at-least-once, so an old instant\n * arriving late must not overwrite a fresh stamp — the same reasoning\n * `recordPersonEngagement` gives. The query for the contact rides inside\n * the transaction so the compare-and-write is atomic against a second event\n * for the same person landing in another instance.\n *\n * ## Only a contact this site may see, and only this holder's facet\n *\n * The contact row is shared by every site in the org. The stamp is written\n * under the sending site's consent group, never at the top of the document,\n * and only onto a row whose `visibleTo` admits the site — a person another\n * holder captured and this site never met is not this site's contact, and a\n * write onto their row would mint a facet for a holder that does not hold\n * them. `updatedAt` is left alone: an open is something the person did, not\n * an edit the team made, and a list sorted on recency must not reshuffle on\n * every mailbox prefetch.\n *\n * ## Never throws\n *\n * Best-effort for the reason everything on the webhook path is: a stamp that\n * failed loses a fact a page can live without, and a stamp that threw would\n * lose the provider's acknowledgement and teach it to retry the whole event.\n */\n\nimport {\n contactFacetPath,\n normalizeContactEmail,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport type { PluginRecordEmailEvent } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n} from '@aglyn/tenant-data-admin/server/organizations'\n\n/** The facet field the stamp lives under. */\nexport const CONTACT_EMAIL_ENGAGEMENT_FIELD = 'lastEmailEngagementAtMs'\n\n/** The event types that count as a person engaging with a campaign. */\nconst ENGAGEMENT_TYPES: ReadonlySet<string> = new Set([\n 'opened',\n 'clicked',\n])\n\n/**\n * Stamps the sending site's contact facet for every person these events\n * say engaged for the first time with a message — the CRM's answer on the\n * core's record email-state seam (`stampRecordEmailEngagement`).\n *\n * @param hostId the site the campaign went out from — the `hostId` tag the\n * send stamped, which is the only tenant identity a delivery event carries.\n * @returns how many contact documents were written.\n */\nexport async function recordContactEmailEngagement(args: {\n hostId: string\n events: readonly PluginRecordEmailEvent[]\n firestore?: any\n}): Promise<number> {\n const hostId = String(args.hostId ?? '')\n if (!hostId) return 0\n\n /** Address → the newest engagement instant in this batch. */\n const byEmail = new Map<string, number>()\n for (const outcome of args.events) {\n if (!outcome.firstOfType) continue\n if (!ENGAGEMENT_TYPES.has(outcome.type)) continue\n const email = normalizeContactEmail(outcome.to)\n const at = Number(outcome.at)\n if (!email || !Number.isFinite(at) || at <= 0) continue\n byEmail.set(email, Math.max(byEmail.get(email) ?? 0, at))\n }\n if (!byEmail.size) return 0\n\n let contactsRef: FirebaseFirestore.CollectionReference\n let groupId: string\n try {\n contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n groupId = (await consentGroupForSite(hostId)).groupId\n } catch (error) {\n console.error('[contact-email-engagement] site could not be resolved', hostId, error)\n return 0\n }\n\n const db = args.firestore ?? firebaseAdmin.app().firestore()\n const field = contactFacetPath(groupId, CONTACT_EMAIL_ENGAGEMENT_FIELD)\n let written = 0\n for (const [email, at] of byEmail) {\n try {\n await db.runTransaction(async (transaction: any) => {\n /*\n * The same unscoped lookup the capture door makes — one human is one\n * row whichever site met them, and through the address index\n * (AGL-2633) the row is found under an address a merge folded into\n * it too — followed by the scope check the capture door's\n * `visibleTo` write is the source of. Read THROUGH the transaction:\n * the stamp below is a compare-and-set against the instant this\n * read saw.\n */\n const snapshot = await findContactByEmail(contactsRef, email, { transaction })\n if (!snapshot) return\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!visibleToHost(data['visibleTo'] as string[] | undefined, hostId)) {\n return\n }\n const stored = Number(\n readContactFacet(data, groupId)[CONTACT_EMAIL_ENGAGEMENT_FIELD] ?? 0,\n )\n // Nothing moved forward, so nothing is written — the out-of-order\n // and the replayed event are the ordinary cases this skips.\n if (Number.isFinite(stored) && stored >= at) return\n transaction.update(snapshot.ref, { [field]: at })\n written += 1\n })\n } catch (error) {\n console.error('[contact-email-engagement] stamp failed', hostId, error)\n }\n }\n return written\n}\n"],"names":["contactFacetPath","normalizeContactEmail","readContactFacet","visibleToHost","findContactByEmail","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","CONTACT_EMAIL_ENGAGEMENT_FIELD","ENGAGEMENT_TYPES","Set","recordContactEmailEngagement","args","hostId","String","byEmail","Map","outcome","events","firstOfType","has","type","email","to","at","Number","isFinite","set","Math","max","get","size","contactsRef","groupId","error","console","db","firestore","app","field","written","runTransaction","transaction","snapshot","data","stored","update","ref"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC,GAED,SACEA,gBAAgB,EAChBC,qBAAqB,EACrBC,gBAAgB,EAChBC,aAAa,QACR,sBAAqB;AAE5B,SAASC,kBAAkB,QAAQ,sDAAqD;AACxF,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SACEC,mBAAmB,EACnBC,wBAAwB,QACnB,gDAA+C;AAEtD,2CAA2C,GAC3C,OAAO,MAAMC,iCAAiC,0BAAyB;AAEvE,qEAAqE,GACrE,MAAMC,mBAAwC,IAAIC,IAAI;IACpD;IACA;CACD;AAED;;;;;;;;CAQC,GACD,OAAO,eAAeC,6BAA6BC,IAIlD;QACuBA,cAyBXA;IAzBX,MAAMC,SAASC,QAAOF,eAAAA,KAAKC,MAAM,YAAXD,eAAe;IACrC,IAAI,CAACC,QAAQ,OAAO;IAEpB,2DAA2D,GAC3D,MAAME,UAAU,IAAIC;IACpB,KAAK,MAAMC,WAAWL,KAAKM,MAAM,CAAE;YAMLH;QAL5B,IAAI,CAACE,QAAQE,WAAW,EAAE;QAC1B,IAAI,CAACV,iBAAiBW,GAAG,CAACH,QAAQI,IAAI,GAAG;QACzC,MAAMC,QAAQrB,sBAAsBgB,QAAQM,EAAE;QAC9C,MAAMC,KAAKC,OAAOR,QAAQO,EAAE;QAC5B,IAAI,CAACF,SAAS,CAACG,OAAOC,QAAQ,CAACF,OAAOA,MAAM,GAAG;QAC/CT,QAAQY,GAAG,CAACL,OAAOM,KAAKC,GAAG,EAACd,eAAAA,QAAQe,GAAG,CAACR,kBAAZP,eAAsB,GAAGS;IACvD;IACA,IAAI,CAACT,QAAQgB,IAAI,EAAE,OAAO;IAE1B,IAAIC;IACJ,IAAIC;IACJ,IAAI;QACFD,cAAc,MAAMzB,yBAAyBM,QAAQ;QACrDoB,UAAU,AAAC,CAAA,MAAM3B,oBAAoBO,OAAM,EAAGoB,OAAO;IACvD,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,yDAAyDrB,QAAQqB;QAC/E,OAAO;IACT;IAEA,MAAME,MAAKxB,kBAAAA,KAAKyB,SAAS,YAAdzB,kBAAkBP,cAAciC,GAAG,GAAGD,SAAS;IAC1D,MAAME,QAAQvC,iBAAiBiC,SAASzB;IACxC,IAAIgC,UAAU;IACd,KAAK,MAAM,CAAClB,OAAOE,GAAG,IAAIT,QAAS;QACjC,IAAI;YACF,MAAMqB,GAAGK,cAAc,CAAC,OAAOC;oBAYfC,gBAKZzC;gBAhBF;;;;;;;;SAQC,GACD,MAAMyC,WAAW,MAAMvC,mBAAmB4B,aAAaV,OAAO;oBAAEoB;gBAAY;gBAC5E,IAAI,CAACC,UAAU;gBACf,MAAMC,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;gBAClC,IAAI,CAACxC,cAAcyC,IAAI,CAAC,YAAY,EAA0B/B,SAAS;oBACrE;gBACF;gBACA,MAAMgC,SAASpB,QACbvB,mDAAAA,iBAAiB0C,MAAMX,QAAQ,CAACzB,+BAA+B,YAA/DN,mDAAmE;gBAErE,kEAAkE;gBAClE,4DAA4D;gBAC5D,IAAIuB,OAAOC,QAAQ,CAACmB,WAAWA,UAAUrB,IAAI;gBAC7CkB,YAAYI,MAAM,CAACH,SAASI,GAAG,EAAE;oBAAE,CAACR,MAAM,EAAEf;gBAAG;gBAC/CgB,WAAW;YACb;QACF,EAAE,OAAON,OAAO;YACdC,QAAQD,KAAK,CAAC,2CAA2CrB,QAAQqB;QACnE;IACF;IACA,OAAOM;AACT"}
@@ -68,7 +68,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
68
68
  * link: summed per company and settled once the contacts are written, so a
69
69
  * company deleted in between cannot fail the save of the people at it — the
70
70
  * company page's live aggregate is what corrects a count that did not move.
71
- */ import { consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, campaignMembershipValue, contactCampaignFieldPath, contactFacetPath, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, normalizeAddress, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
71
+ */ import { consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, containerMembershipValue, contactContainerFieldPath, contactFacetPath, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, normalizeAddress, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
72
72
  import { companyContactsCountFields, contactCompanyLinkFields, firebaseAdmin, resolveOrgMembership, restampCrmListFieldsOf } from "@aglyn/tenant-data-admin";
73
73
  import { FieldValue } from "firebase-admin/firestore";
74
74
  import { CONTACT_UPDATE_FIELDS, CRM_CONTACT_UPDATE_MAX } from "../model/contact-update.js";
@@ -180,7 +180,7 @@ import { contactPrimaryGroup } from "../model/contact-holder.js";
180
180
  ok: false,
181
181
  error: 'Campaigns must be a list.'
182
182
  };
183
- fields.campaignIds = campaignMembershipValue(ids.map((id)=>String(id != null ? id : '')));
183
+ fields.campaignIds = containerMembershipValue(ids.map((id)=>String(id != null ? id : '')));
184
184
  }
185
185
  if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128);
186
186
  if ('companyId' in raw) {
@@ -284,7 +284,7 @@ const refused = (contactId, error)=>({
284
284
  update[path('tags')] = FieldValue.arrayRemove(fields.removeTag);
285
285
  }
286
286
  if (fields.campaignIds !== undefined) {
287
- update[contactCampaignFieldPath(groupId)] = fields.campaignIds;
287
+ update[contactContainerFieldPath(groupId, 'campaign')] = fields.campaignIds;
288
288
  }
289
289
  if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid);
290
290
  if (fields.companyId !== undefined) {