@aglyn/plugins-crm 1.0.0 → 1.1.0

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 (154) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/activity-link-fields.d.ts +34 -0
  3. package/src/lib/components/activity-link-fields.js +141 -0
  4. package/src/lib/components/activity-link-fields.js.map +1 -0
  5. package/src/lib/components/activity-list.d.ts +8 -0
  6. package/src/lib/components/activity-list.js +4 -2
  7. package/src/lib/components/activity-list.js.map +1 -1
  8. package/src/lib/components/activity-queries.d.ts +7 -2
  9. package/src/lib/components/activity-queries.js +7 -3
  10. package/src/lib/components/activity-queries.js.map +1 -1
  11. package/src/lib/components/company-contact-roles-card.d.ts +42 -0
  12. package/src/lib/components/company-contact-roles-card.js +135 -0
  13. package/src/lib/components/company-contact-roles-card.js.map +1 -0
  14. package/src/lib/components/company-detail-page.js +12 -1
  15. package/src/lib/components/company-detail-page.js.map +1 -1
  16. package/src/lib/components/company-edit-drawer.js +9 -6
  17. package/src/lib/components/company-edit-drawer.js.map +1 -1
  18. package/src/lib/components/company-picker.js +4 -2
  19. package/src/lib/components/company-picker.js.map +1 -1
  20. package/src/lib/components/contact-associations-card.js +15 -1
  21. package/src/lib/components/contact-associations-card.js.map +1 -1
  22. package/src/lib/components/contact-detail-page.js +13 -14
  23. package/src/lib/components/contact-detail-page.js.map +1 -1
  24. package/src/lib/components/contact-list-columns.js +5 -4
  25. package/src/lib/components/contact-list-columns.js.map +1 -1
  26. package/src/lib/components/contact-properties-card.js +41 -20
  27. package/src/lib/components/contact-properties-card.js.map +1 -1
  28. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  29. package/src/lib/components/contact-roles-editor.js +270 -0
  30. package/src/lib/components/contact-roles-editor.js.map +1 -0
  31. package/src/lib/components/contacts-section.js +6 -4
  32. package/src/lib/components/contacts-section.js.map +1 -1
  33. package/src/lib/components/crm-console-sections.js +1 -1
  34. package/src/lib/components/crm-console-sections.js.map +1 -1
  35. package/src/lib/components/crm-record-picker.d.ts +19 -0
  36. package/src/lib/components/crm-record-picker.js +40 -11
  37. package/src/lib/components/crm-record-picker.js.map +1 -1
  38. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  39. package/src/lib/components/crm-related-to-picker.js +135 -0
  40. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  41. package/src/lib/components/crm-site-picker.d.ts +13 -1
  42. package/src/lib/components/crm-site-picker.js +15 -6
  43. package/src/lib/components/crm-site-picker.js.map +1 -1
  44. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  45. package/src/lib/components/crm-transfer-buttons.js +9 -6
  46. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  47. package/src/lib/components/deal-contact-roles-card.js +21 -239
  48. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  49. package/src/lib/components/deal-detail-page.js +2 -1
  50. package/src/lib/components/deal-detail-page.js.map +1 -1
  51. package/src/lib/components/deal-edit-drawer.js +11 -7
  52. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  53. package/src/lib/components/deals-section.js +5 -4
  54. package/src/lib/components/deals-section.js.map +1 -1
  55. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  56. package/src/lib/components/log-activity-dialog.js +96 -32
  57. package/src/lib/components/log-activity-dialog.js.map +1 -1
  58. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  59. package/src/lib/components/new-contact-drawer.js +27 -15
  60. package/src/lib/components/new-contact-drawer.js.map +1 -1
  61. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  62. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  63. package/src/lib/components/record-activity-card.d.ts +11 -2
  64. package/src/lib/components/record-activity-card.js +90 -5
  65. package/src/lib/components/record-activity-card.js.map +1 -1
  66. package/src/lib/components/settings-section.d.ts +4 -2
  67. package/src/lib/components/settings-section.js +7 -5
  68. package/src/lib/components/settings-section.js.map +1 -1
  69. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  70. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  71. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  72. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  73. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  74. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  75. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  76. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  77. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  78. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  79. package/src/lib/hooks/use-crm-scope.js +12 -5
  80. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  81. package/src/lib/model/contact-holder.d.ts +12 -0
  82. package/src/lib/model/contact-holder.js +20 -1
  83. package/src/lib/model/contact-holder.js.map +1 -1
  84. package/src/lib/model/contact-record.d.ts +5 -0
  85. package/src/lib/model/contact-record.js +7 -1
  86. package/src/lib/model/contact-record.js.map +1 -1
  87. package/src/lib/model/contact-update.d.ts +6 -0
  88. package/src/lib/model/contact-update.js +1 -0
  89. package/src/lib/model/contact-update.js.map +1 -1
  90. package/src/lib/model/crm-activity-link.d.ts +117 -0
  91. package/src/lib/model/crm-activity-link.js +113 -0
  92. package/src/lib/model/crm-activity-link.js.map +1 -0
  93. package/src/lib/model/crm-api.d.ts +4 -1
  94. package/src/lib/model/crm-api.js +9 -5
  95. package/src/lib/model/crm-api.js.map +1 -1
  96. package/src/lib/model/deal-board-model.js +5 -3
  97. package/src/lib/model/deal-board-model.js.map +1 -1
  98. package/src/lib/model/deal-contact-roles.d.ts +15 -1
  99. package/src/lib/model/deal-contact-roles.js +17 -3
  100. package/src/lib/model/deal-contact-roles.js.map +1 -1
  101. package/src/lib/model/settle-stage-names.d.ts +25 -0
  102. package/src/lib/model/settle-stage-names.js +29 -0
  103. package/src/lib/model/settle-stage-names.js.map +1 -0
  104. package/src/lib/native-nav.js +1 -1
  105. package/src/lib/native-nav.js.map +1 -1
  106. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  107. package/src/lib/server/add-contact-by-hand.js +212 -0
  108. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  109. package/src/lib/server/api-v1/contacts.js +61 -23
  110. package/src/lib/server/api-v1/contacts.js.map +1 -1
  111. package/src/lib/server/api-v1/crm-activities.js +13 -3
  112. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  113. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  114. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  115. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  116. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  117. package/src/lib/server/api-v1/crm-shared.js +23 -11
  118. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  119. package/src/lib/server/api-v1/openapi.js +3 -3
  120. package/src/lib/server/api-v1/openapi.js.map +1 -1
  121. package/src/lib/server/automation-steps.js +11 -9
  122. package/src/lib/server/automation-steps.js.map +1 -1
  123. package/src/lib/server/campaign-filing-activity.js +7 -2
  124. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  125. package/src/lib/server/contact-update.d.ts +4 -0
  126. package/src/lib/server/contact-update.js +71 -10
  127. package/src/lib/server/contact-update.js.map +1 -1
  128. package/src/lib/server/convert-host-lead.js +13 -2
  129. package/src/lib/server/convert-host-lead.js.map +1 -1
  130. package/src/lib/server/convert-open-lead.js +8 -1
  131. package/src/lib/server/convert-open-lead.js.map +1 -1
  132. package/src/lib/server/crm-activity-company.d.ts +25 -0
  133. package/src/lib/server/crm-activity-company.js +34 -0
  134. package/src/lib/server/crm-activity-company.js.map +1 -0
  135. package/src/lib/server/crm-inbound-email.js +21 -15
  136. package/src/lib/server/crm-inbound-email.js.map +1 -1
  137. package/src/lib/server/crm-sharing.js +9 -2
  138. package/src/lib/server/crm-sharing.js.map +1 -1
  139. package/src/lib/server/email-send.js +10 -8
  140. package/src/lib/server/email-send.js.map +1 -1
  141. package/src/lib/server/merge-contacts.js +6 -2
  142. package/src/lib/server/merge-contacts.js.map +1 -1
  143. package/src/lib/server/org-caller.d.ts +8 -0
  144. package/src/lib/server/org-caller.js +10 -0
  145. package/src/lib/server/org-caller.js.map +1 -1
  146. package/src/lib/server/record-timeline.d.ts +3 -1
  147. package/src/lib/server/record-timeline.js +11 -3
  148. package/src/lib/server/record-timeline.js.map +1 -1
  149. package/src/lib/server.js +152 -43
  150. package/src/lib/server.js.map +1 -1
  151. package/src/lib/transfer/contacts.js +83 -48
  152. package/src/lib/transfer/contacts.js.map +1 -1
  153. package/src/lib/transfer/fields.js +1 -2
  154. package/src/lib/transfer/fields.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/contacts.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 { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n type AglynPostalAddress,\n CAPTURED_BY_HOST_FIELD,\n composeContactName,\n CONTACT_BIRTHDATE_REFUSAL,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_EXTRA_PHONE_FIELDS,\n CONTACT_FACETS_FIELD,\n CONTACT_FIELDS_MAX_PER_ORG,\n CONTACT_LIFECYCLE_STAGES,\n CONTACT_NAME_PART_MAX,\n type ConsentGroup,\n consentGroupForHost,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n contactFacetPath,\n type ContactFieldDefinition,\n type ContactLifecycleStage,\n createResourceUid,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_MAX,\n CRM_SALUTATION_PICKLIST,\n isContactLifecycleStage,\n judgeCrmPicklistValue,\n MARKETING_CONSENT_BY_HOST_FIELD,\n marketingConsentFieldsForGroup,\n marketingConsentHostIds,\n normalizeAddress,\n normalizeContactBirthdate,\n normalizeContactEmail,\n normalizeCrmMediaIds,\n normalizePhone,\n ORG_SCOPE_TOKEN,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactCustomInput,\n readContactFacet,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n ApiErrors,\n apiJson,\n contactCompanyMirrorValue,\n crmRecordsQuotaForOrg,\n listResponse,\n restampCrmListFieldsAt,\n settleCompanyContactsCounts,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n claimWrite,\n orgOwnsHost,\n paginate,\n readJsonBody,\n requireScope,\n serialize,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport { scheduleCapturedEmailCheck } from '@aglyn/tenant-data-admin/server/capture-email-check'\n// The leaf, not the barrel: the console's API specs substitute the barrel\n// wholesale, and the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\n// The leaf for the same reason: the refusals a deleted contact held must be\n// kept by the real store under a spec's barrel (AGL-3338).\nimport {\n deleteWholeContact,\n removeContactKeepingRefusals,\n} from '@aglyn/tenant-data-admin/server/retained-refusals'\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\nimport { mergeContactRoute } from './contacts-merge'\nimport { sweepDealContactRoles } from '../deal-contact-roles'\nimport {\n cachedContactReader,\n clearReportsToOf,\n CONTACT_REPORTS_TO_LOOP_REFUSAL,\n CONTACT_REPORTS_TO_SELF_REFUSAL,\n contactReportsToLoops,\n} from '../contact-reports-to'\nimport { readCrmPicklist } from '../read-picklist'\nimport {\n CRM_ID_MAX,\n CRM_LABEL_MAX,\n createPayload,\n crmRefErrors,\n memberError,\n readChoice,\n readOptionalText,\n readOrgLeadSourcePicklist,\n readRefId,\n updatePayload,\n} from './crm-shared'\nimport { resolveCrmPicklistWrite } from '../read-picklist'\n\n/**\n * `/v1/contacts` (AGL-618, AGL-2276, AGL-2606): the organization's people —\n * list, read, create, update, delete, and `POST /v1/contacts/{id}/merge` —\n * served by the CRM, which keeps them. The pipeline in front of it (the key,\n * the plan's API access, the quota, the rate limit, the envelope) is the\n * console router's; the CRM suite's plan refusal is declared with the\n * registration (`declarations.console-server.ts`).\n */\n\n// ── Contacts (read) ─────────────────────────────────────────────────────────\n\n/**\n * The contact object as published.\n *\n * `notes` and `marketingConsent` are here because `PATCH` writes them\n * (AGL-2276). A projection that omits a field the same resource accepts is\n * the shape that has shipped broken before: the client writes, reads back,\n * sees nothing, and cannot tell a dropped write from a narrow view. Every\n * writable field appears here, and `contact-writes.spec.ts` asserts that as a\n * property of the pair rather than field by field.\n *\n * `email`, `sources` and `interactions` are read-only and stay that way —\n * `email` is the dedupe key the whole CRM unifies on, and the other two are\n * provenance. `interactions` is not published at all; the console's timeline\n * is not part of the API contract.\n */\n/**\n * The CRM profile a contact carries (AGL-2606): the six fields a sales team\n * keeps on a person, read from the FACETS because that is where the console\n * keeps them — per holder, under `facets.{groupId}` (`ContactFacet`), so two\n * unrelated businesses sharing one row never read each other's knowledge of\n * the person.\n *\n * An API key is an organization credential, and every facet on the row is\n * the organization's own; so with no site named the profile is the UNION of\n * the holders — each field from the first holder, in stable id order, that\n * has set it. That is the right read for the caller a key represents (an\n * integration acting for the account) and the only one that does not force\n * every read to name a site. Naming one (`?consentSiteId=`) reads that\n * site's group alone, which is what a per-brand sync wants and what a PATCH\n * that just wrote through that site reads back.\n *\n * `null` for every unset field rather than an absent key, so a client can\n * tell \"no phone\" from \"this API does not publish phones\".\n */\nconst CONTACT_CRM_FIELDS = [\n 'phone',\n 'jobTitle',\n 'companyId',\n 'address',\n 'ownerUid',\n 'lifecycleStage',\n // The holder's Lead source, one of the org's picklist values (AGL-3511).\n 'leadSource',\n // Files are a facet field like the rest (AGL-2662): an agency running two\n // client brands has one contact document between them, and a contract one\n // client filed is not the other client's to read.\n 'mediaIds',\n // Salesforce's standard contact fields (AGL-3515), per holder like the rest.\n 'salutation',\n 'firstName',\n 'lastName',\n 'department',\n 'mobilePhone',\n 'homePhone',\n 'otherPhone',\n 'fax',\n 'birthdate',\n 'assistantName',\n 'assistantPhone',\n 'reportsToContactId',\n 'otherAddress',\n 'doNotCall',\n] as const\n\ntype ContactCrmField = (typeof CONTACT_CRM_FIELDS)[number]\n\n/** The groups holding a facet on this row, in stable order. */\nfunction contactFacetHolders(data: FirebaseFirestore.DocumentData): string[] {\n const facets = data[CONTACT_FACETS_FIELD]\n return facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets).sort()\n : []\n}\n\nfunction contactCrmProfile(\n data: FirebaseFirestore.DocumentData,\n groupId: string | null,\n): Record<ContactCrmField, unknown> {\n const profile: Record<ContactCrmField, unknown> = {\n phone: null,\n jobTitle: null,\n companyId: null,\n address: null,\n ownerUid: null,\n lifecycleStage: null,\n leadSource: null,\n // An empty ARRAY rather than null, so a client can index it without a\n // guard — the same shape `custom` publishes for the same reason.\n mediaIds: [],\n salutation: null,\n firstName: null,\n lastName: null,\n department: null,\n mobilePhone: null,\n homePhone: null,\n otherPhone: null,\n fax: null,\n birthdate: null,\n assistantName: null,\n assistantPhone: null,\n reportsToContactId: null,\n otherAddress: null,\n // A flag reads `false` rather than null: unset IS \"may be called\".\n doNotCall: false,\n }\n for (const holder of groupId ? [groupId] : contactFacetHolders(data)) {\n const facet = readContactFacet(data, holder)\n for (const field of CONTACT_CRM_FIELDS) {\n const value = facet[field]\n if (field === 'mediaIds') {\n // The union of every holder's attachments when no site was named,\n // and one holder's when one was — the same rule the scalar fields\n // follow, spelled apart because a list merges rather than wins.\n const held = profile[field] as string[]\n if (!held.length) profile[field] = normalizeCrmMediaIds(value)\n continue\n }\n if (field === 'doNotCall') {\n if (value === true) profile[field] = true\n continue\n }\n if (profile[field] === null && value !== undefined && value !== null) {\n profile[field] = value\n }\n }\n }\n return profile\n}\n\n/**\n * @param groupId - the holder whose CRM profile to publish, or `null` for\n * the union — see {@link contactCrmProfile}.\n */\nfunction contactView(\n doc: FirebaseFirestore.DocumentSnapshot,\n groupId: string | null = null,\n) {\n const data = doc.data() ?? {}\n return {\n id: doc.id,\n object: 'contact',\n email: data.email ?? null,\n name: data.name ?? null,\n tags: data.tags ?? [],\n notes: data.notes ?? null,\n ...contactCrmProfile(data, groupId),\n // Every company any holder has filed the person under — the top-level\n // twin of the facets' `companyId`, and what `?companyId=` queries.\n companyIds: Array.isArray(data.companyIds)\n ? data.companyIds.filter((id: unknown) => typeof id === 'string')\n : [],\n // The other addresses a merge folded into this record (AGL-2625) —\n // identity like `email`, and read-only for the same reason.\n alternateEmails: Array.isArray(data.alternateEmails)\n ? data.alternateEmails.filter((email: unknown) => typeof email === 'string')\n : [],\n /*\n * TRUE means \"some site may mail this person\", and `consentSites` says\n * which. A single boolean is what the org-wide model published, and it is\n * exactly the claim that turned out to be wrong: an agency's key read\n * `true` and could not tell which of its brands the person had agreed to\n * hear from. Both are published so a client can act on either.\n */\n marketingConsent:\n data.marketingConsent === false\n ? false\n : marketingConsentHostIds(data).length > 0,\n consentSites: marketingConsentHostIds(data),\n sources: data.sources ? Object.keys(data.sources) : [],\n /*\n * The org's custom field values, keyed by field key (AGL-2601). The same\n * top-level map `tags` and `notes` beside it are read from — this resource\n * is the ORGANIZATION's view, and its writes land there. An empty object\n * rather than `null`, so a client can index it without a guard.\n */\n custom:\n data.custom && typeof data.custom === 'object' && !Array.isArray(data.custom)\n ? (data.custom as Record<string, ContactCustomValue>)\n : {},\n created: serialize(data.createdAt) ?? null,\n updated: serialize(data.updatedAt) ?? null,\n }\n}\n\n/**\n * The org's custom field definitions, retired ones included, for the API's\n * `custom` validation (AGL-2601).\n *\n * One bounded read of a small collection, paid only when a body carries\n * `custom` — the reader is called from the two writers, never from the view.\n * Retired definitions come back too because `readContactCustomInput` refuses\n * a write under one BY NAME, which is a better answer than \"no such field\"\n * for a key the integration wrote last month.\n */\nasync function readOrgContactFieldDefinitions(\n ctx: ApiV1Context,\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await ctx.firestore\n .collection('orgs')\n .doc(ctx.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .orderBy(FieldPath.documentId())\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n return snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition)\n}\n\nconst CONTACT_NAME_MAX = 120\nconst CONTACT_NOTES_MAX = 2000\nconst CONTACT_TAGS_MAX = 50\nconst CONTACT_TAG_MAX = 60\n\n/** Fields a client may send. Anything else is named, never silently dropped. */\nconst CONTACT_WRITABLE = [\n 'name',\n 'tags',\n 'notes',\n 'marketingConsent',\n 'consentSiteId',\n 'consentGroupId',\n 'custom',\n] as const\n\n/**\n * The sites an API opt-in is recorded for (AGL-3320): the named site's whole\n * consent group only when the body ECHOED that group's id, and the site\n * alone otherwise.\n *\n * An integration's own signup form is a capture surface this platform never\n * sees, so it cannot prove which sentence it showed the way a Form block\n * does. Echoing the group's id is the integration saying it showed the\n * group's name; an opt-in that says nothing about the group, or names one\n * the site is no longer in, is the one site's — narrow, which is the safe\n * way to be wrong, and visible in the response's `consentSites`.\n */\nfunction apiGrantGroup(\n ctx: ApiV1Context,\n siteId: string,\n echoedGroupId: string | undefined,\n): ConsentGroup {\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n return group.declared && echoedGroupId === group.groupId\n ? group\n : soloConsentGroup(siteId)\n}\n\n/**\n * Validate the writable half of a contact (AGL-2276). `partial` separates\n * PATCH from POST exactly as `readDatasetInput` does: a create must carry an\n * `email`, an update may send any one field alone and leaves the rest alone.\n *\n * Unknown keys are REFUSED rather than dropped, following\n * `updateFormSubmission` and not `createRecord`: a record has a dataset model\n * that defines what exists, and a contact does not, so a silent drop would\n * read as \"we stored your correction\" when nothing was stored. `email` gets\n * its own message on PATCH because sending it is the honest mistake an\n * integrator makes first — it is the field their own system keys on.\n */\n/**\n * The CRM profile fields a contact write may carry (AGL-2606), each `null`\n * to clear. A `type` rather than an `interface` so it is a\n * `Record<string, unknown>` to the payload helpers without a cast.\n */\ntype ContactCrmInput = {\n phone?: string | null\n jobTitle?: string | null\n companyId?: string | null\n address?: AglynPostalAddress | null\n ownerUid?: string | null\n lifecycleStage?: ContactLifecycleStage | null\n /** A value of the org's Lead source picklist, judged by the writer (AGL-3511). */\n leadSource?: string | null\n /** Org-library files attached by this holder (AGL-2662), by media id. */\n mediaIds?: string[]\n /* Salesforce's standard contact fields (AGL-3515). */\n salutation?: string | null\n firstName?: string | null\n lastName?: string | null\n department?: string | null\n mobilePhone?: string | null\n homePhone?: string | null\n otherPhone?: string | null\n fax?: string | null\n birthdate?: string | null\n assistantName?: string | null\n assistantPhone?: string | null\n reportsToContactId?: string | null\n otherAddress?: AglynPostalAddress | null\n /** Stored only as `true`; `false` reads as `null`, which clears. */\n doNotCall?: true | null\n}\n\nfunction readContactInput(\n body: Record<string, unknown>,\n { partial }: { partial: boolean },\n):\n | {\n values: {\n email?: string\n name?: string\n tags?: string[]\n notes?: string\n marketingConsent?: boolean\n consentSiteId?: string\n /** The consent group the opt-in was disclosed under (AGL-3320). */\n consentGroupId?: string\n /**\n * The `custom` map as SENT, shape-checked only. Its keys and values\n * are judged against the org's definitions by the writer, which is\n * the side holding a Firestore handle (AGL-2601).\n */\n custom?: Record<string, unknown>\n crm: ContactCrmInput\n }\n }\n | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n const values: {\n email?: string\n name?: string\n tags?: string[]\n notes?: string\n marketingConsent?: boolean\n consentSiteId?: string\n consentGroupId?: string\n custom?: Record<string, unknown>\n crm: ContactCrmInput\n } = { crm: {} }\n\n const allowed = new Set<string>([\n ...CONTACT_WRITABLE,\n ...CONTACT_CRM_FIELDS,\n ...(partial ? [] : ['email']),\n ])\n for (const key of Object.keys(body)) {\n if (allowed.has(key)) continue\n errors[key] =\n key === 'email'\n ? 'Not writable — a contact is identified by its email'\n : key === 'sources' || key === 'interactions'\n ? 'Not writable — set by the capture point that recorded it'\n : 'Not writable on a contact'\n }\n\n if (!partial) {\n // The SAME normalizer every capture point uses. Re-implementing the\n // check here would let the API accept an address `upsertHostContact`\n // would reject, and the two would then disagree about who is a duplicate.\n const email = normalizeContactEmail(body.email)\n if (!email) errors.email = 'A valid email address is required'\n else values.email = email\n }\n\n if (body.name !== undefined) {\n const name = String(body.name ?? '').trim().slice(0, CONTACT_NAME_MAX)\n if (name) values.name = name\n else errors.name = 'Must not be empty'\n }\n\n if (body.tags !== undefined) {\n if (!Array.isArray(body.tags)) {\n errors.tags = 'Must be an array of strings'\n } else {\n // An EMPTY array is legal and means \"clear the tags\" — the console's\n // own tag editor can empty the field, and an API that could only ever\n // add tags would leave an integration unable to undo its own mistake.\n values.tags = (body.tags as unknown[])\n .map((tag) => String(tag).trim().slice(0, CONTACT_TAG_MAX))\n .filter((tag) => tag.length > 0)\n .slice(0, CONTACT_TAGS_MAX)\n }\n }\n\n if (body.notes !== undefined) {\n values.notes = String(body.notes ?? '').slice(0, CONTACT_NOTES_MAX)\n }\n\n if (body.consentSiteId !== undefined) {\n const siteId = String(body.consentSiteId ?? '').trim()\n if (!siteId) errors.consentSiteId = 'Must name a site'\n else values.consentSiteId = siteId\n }\n\n if (body.consentGroupId !== undefined) {\n const groupId =\n typeof body.consentGroupId === 'string' ? body.consentGroupId.trim() : ''\n if (!groupId) errors.consentGroupId = 'Must name a consent group'\n else values.consentGroupId = groupId\n }\n\n if (body.marketingConsent !== undefined) {\n if (typeof body.marketingConsent !== 'boolean') {\n errors.marketingConsent = 'Must be true or false'\n } else {\n values.marketingConsent = body.marketingConsent\n }\n }\n\n if (body.custom !== undefined) {\n if (!body.custom || typeof body.custom !== 'object' || Array.isArray(body.custom)) {\n errors.custom = 'Must be an object of field values keyed by field key'\n } else {\n values.custom = body.custom as Record<string, unknown>\n }\n }\n\n /*\n * The CRM profile (AGL-2606). Each value runs through the normalizer the\n * console writes with — `normalizePhone`, `normalizeAddress`, the lifecycle\n * list — so the API cannot store a phone the console would refuse, or an\n * address of empty strings the console would read as \"has an address\". A\n * value that does not survive names the field rather than storing the raw\n * string: a half-normalized phone is the unusable number `normalizePhone`\n * exists to end. The readers are `crm-shared`'s, so a contact and a company\n * bound their text the same way. `ownerUid` and `companyId` are checked for\n * existence by the caller, after this synchronous pass, so a body already\n * refused never spends the reads.\n */\n const crm: ContactCrmInput = {}\n const phone = readOptionalText(body, 'phone', CRM_LABEL_MAX, errors)\n if (phone === null) {\n crm.phone = null\n } else if (phone !== undefined) {\n const normalized = normalizePhone(phone)\n if (normalized) crm.phone = normalized\n else errors.phone = 'Must be a phone number with a country code, like +15125550123'\n }\n const jobTitle = readOptionalText(body, 'jobTitle', CONTACT_NAME_MAX, errors)\n if (jobTitle !== undefined) crm.jobTitle = jobTitle\n const companyId = readRefId(body, 'companyId', errors)\n if (companyId !== undefined) crm.companyId = companyId\n if (body.address !== undefined) {\n if (body.address === null) {\n crm.address = null\n } else if (typeof body.address !== 'object' || Array.isArray(body.address)) {\n errors.address = 'Must be an address object'\n } else {\n // A blank address normalizes to `null`, which clears.\n crm.address = normalizeAddress(body.address as AglynPostalAddress)\n }\n }\n const ownerUid = readOptionalText(body, 'ownerUid', CONTACT_NAME_MAX, errors)\n if (ownerUid !== undefined) crm.ownerUid = ownerUid\n // Shape only here; the org's list judges the value in `contactCrmRefErrors`.\n const leadSource = readOptionalText(body, 'leadSource', CRM_LABEL_MAX, errors)\n if (leadSource !== undefined) crm.leadSource = leadSource\n if (body.mediaIds !== undefined) {\n if (!Array.isArray(body.mediaIds)) {\n errors.mediaIds = 'Must be an array of media ids'\n } else if (body.mediaIds.length > CRM_MEDIA_IDS_MAX) {\n errors.mediaIds = `At most ${CRM_MEDIA_IDS_MAX} files may be attached`\n } else {\n // The SAME normalizer the console card writes through. An empty array\n // is legal and clears this holder's attachments.\n crm.mediaIds = normalizeCrmMediaIds(body.mediaIds)\n }\n }\n if (body.lifecycleStage === null) {\n crm.lifecycleStage = null\n } else {\n const stage = readChoice(body, 'lifecycleStage', CONTACT_LIFECYCLE_STAGES, errors)\n if (stage) crm.lifecycleStage = stage\n }\n /*\n * Salesforce's standard contact fields (AGL-3515), through the normalizers\n * the console's profile route uses. The salutation and the reports-to are\n * judged after this pass, against the org's list and its contacts.\n */\n const salutation = readOptionalText(body, 'salutation', CRM_LABEL_MAX, errors)\n if (salutation !== undefined) crm.salutation = salutation\n for (const key of ['firstName', 'lastName'] as const) {\n const part = readOptionalText(body, key, CONTACT_NAME_PART_MAX, errors)\n if (part !== undefined) crm[key] = part\n }\n for (const key of ['department', 'assistantName'] as const) {\n const text = readOptionalText(body, key, CONTACT_NAME_MAX, errors)\n if (text !== undefined) crm[key] = text\n }\n for (const key of CONTACT_EXTRA_PHONE_FIELDS) {\n const text = readOptionalText(body, key, CRM_LABEL_MAX, errors)\n if (text === null) {\n crm[key] = null\n } else if (text !== undefined) {\n const normalized = normalizePhone(text)\n if (normalized) crm[key] = normalized\n else errors[key] = 'Must be a phone number with a country code, like +15125550123'\n }\n }\n const birthdate = readOptionalText(body, 'birthdate', 32, errors)\n if (birthdate === null) {\n crm.birthdate = null\n } else if (birthdate !== undefined) {\n const normalized = normalizeContactBirthdate(birthdate)\n if (normalized) crm.birthdate = normalized\n else errors.birthdate = CONTACT_BIRTHDATE_REFUSAL\n }\n const reportsTo = readRefId(body, 'reportsToContactId', errors)\n if (reportsTo !== undefined) crm.reportsToContactId = reportsTo\n if (body.otherAddress !== undefined) {\n if (body.otherAddress === null) {\n crm.otherAddress = null\n } else if (typeof body.otherAddress !== 'object' || Array.isArray(body.otherAddress)) {\n errors.otherAddress = 'Must be an address object'\n } else {\n crm.otherAddress = normalizeAddress(body.otherAddress as AglynPostalAddress)\n }\n }\n if (body.doNotCall !== undefined) {\n if (body.doNotCall === null || body.doNotCall === false) crm.doNotCall = null\n else if (body.doNotCall === true) crm.doNotCall = true\n else errors.doNotCall = 'Must be true or false'\n }\n values.crm = crm\n // Whether the body MEANT to write a profile — judged by the keys it sent,\n // not by which of them parsed, so a body whose every profile value was\n // refused is told about those values and not also that its site was\n // unwelcome.\n const writesCrm = CONTACT_CRM_FIELDS.some((field) => body[field] !== undefined)\n\n /*\n * AN OPT-IN MUST NAME THE SITE IT WAS GIVEN TO; A REFUSAL MUST NOT.\n *\n * An API key belongs to an ORGANIZATION, and an organization is not a\n * controller — an agency's key reaches every client brand it runs. So an\n * integration asserting that somebody opted in has to say which brand they\n * opted in to, exactly as a form does by being served from one site. There\n * is no safe default: picking the org's only site works until the org has\n * two, and picking none is the org-wide grant this field exists to stop.\n *\n * A refusal is the mirror image and is refused a site on purpose. It\n * applies to every brand in the account, which is what `readMarketingBasis`\n * does with an unscoped `false`, and accepting a site alongside it would\n * imply a per-brand opt-out this endpoint does not write.\n */\n if (values.marketingConsent === true && !values.consentSiteId) {\n errors.consentSiteId =\n 'Required with marketingConsent: true — name the site the person opted in to'\n }\n /*\n * A CRM PROFILE FIELD NAMES THE SITE TOO (AGL-2606), for the facet's\n * reason: the profile is one holder's knowledge of the person and lives\n * under that holder's group, so a write has to say whose. The same\n * parameter, because it is the same question — which of the organization's\n * sites is this write made on behalf of — and an integrator should not\n * learn two names for it. Beside a refusal the site is accepted, since the\n * refusal still applies to every site and the site now names the facet.\n */\n if (writesCrm && !values.consentSiteId) {\n errors.consentSiteId =\n 'Required with a CRM profile field — name the site whose profile of this person to write'\n }\n if (values.marketingConsent === false && values.consentSiteId && !writesCrm) {\n errors.consentSiteId =\n 'Not accepted with marketingConsent: false — a refusal applies to every site'\n }\n if (values.consentSiteId && values.marketingConsent === undefined && !writesCrm) {\n errors.consentSiteId =\n 'Only accepted alongside marketingConsent or a CRM profile field'\n }\n /*\n * THE GROUP IS ECHOED ONLY BESIDE AN OPT-IN (AGL-3320). It says which\n * disclosure the person saw, so it widens a grant to the site's consent\n * group and means nothing anywhere else — least of all beside a refusal,\n * which already stands against every site.\n */\n if (values.consentGroupId && values.marketingConsent !== true) {\n errors.consentGroupId =\n 'Only accepted with marketingConsent: true — name the consent group the person was shown'\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\n\n/**\n * The two references a contact's CRM profile can carry, checked for\n * existence — the SAME checks a deal or a task makes on its own `ownerUid`\n * and `companyId`, so a contact cannot point at a company `/v1/deals` would\n * refuse. Run after the synchronous grammar, so a body already refused never\n * spends the reads.\n */\nasync function contactCrmRefErrors(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n at: ContactWriteAt = { contactId: null },\n): Promise<Record<string, string>> {\n const [owner, refs, leadSource, salutation, reportsTo] = await Promise.all([\n memberError(ctx, 'ownerUid', crm.ownerUid),\n crmRefErrors(ctx, { companyId: crm.companyId ?? undefined }),\n // The holder's current lead source is kept even when the list no longer offers it.\n contactLeadSourceErrors(ctx, crm, facetAt(ctx, at)?.facet.leadSource),\n contactSalutationError(ctx, crm, at),\n contactReportsToError(ctx, crm, at),\n ])\n return { ...owner, ...refs, ...leadSource, ...salutation, ...reportsTo }\n}\n\n/**\n * The lead source a contact write stores (AGL-3511), judged against the\n * org's Lead source list exactly as a lead's is: an active value stores the\n * list's spelling, the holder's current value is always kept, and anything\n * else is refused naming what the list allows. Rewrites `crm.leadSource` to\n * the label the list spells. A contact takes no default — the list's default\n * is for a new LEAD.\n */\nasync function contactLeadSourceErrors(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n current: unknown,\n): Promise<Record<string, string>> {\n if (typeof crm.leadSource !== 'string') return {}\n const resolved = resolveCrmPicklistWrite(\n CRM_LEAD_SOURCE_PICKLIST,\n await readOrgLeadSourcePicklist(ctx),\n crm.leadSource,\n { current, created: false },\n )\n if (resolved.ok === false) return { leadSource: resolved.error }\n crm.leadSource = resolved.write ?? null\n return {}\n}\n\n/** The contact a write lands on — `null` for a create — and the site naming its facet. */\ninterface ContactWriteAt {\n contactId: string | null\n siteId?: string\n data?: FirebaseFirestore.DocumentData\n}\n\n/** The facet a write at `at` reads its current values from. */\nfunction facetAt(ctx: ApiV1Context, at: ContactWriteAt) {\n if (!at.data || !at.siteId) return null\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, at.siteId)\n return { groupId: group.groupId, facet: readContactFacet(at.data, group.groupId) }\n}\n\n/**\n * The salutation, judged against the org's list (AGL-3515) the way the\n * console's route judges it: an active value stores the list's spelling,\n * the value the contact already holds is kept, anything else is named with\n * the values allowed. Rewrites `crm.salutation` to the stored spelling.\n */\nasync function contactSalutationError(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n at: ContactWriteAt,\n): Promise<Record<string, string>> {\n if (!crm.salutation) return {}\n const judged = judgeCrmPicklistValue(\n CRM_SALUTATION_PICKLIST,\n await readCrmPicklist(ctx.firestore, ctx.orgId, CRM_SALUTATION_PICKLIST),\n crm.salutation,\n facetAt(ctx, at)?.facet.salutation,\n )\n if (judged.ok === false) return { salutation: judged.error }\n crm.salutation = judged.value ?? null\n return {}\n}\n\n/**\n * The reports-to (AGL-3515): a contact of this organization, never the\n * contact itself, and never one whose own chain — through the named site's\n * facets — leads back to it.\n */\nasync function contactReportsToError(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n at: ContactWriteAt,\n): Promise<Record<string, string>> {\n const managerId = crm.reportsToContactId\n if (!managerId) return {}\n if (managerId === at.contactId) {\n return { reportsToContactId: CONTACT_REPORTS_TO_SELF_REFUSAL }\n }\n const read = cachedContactReader(contactsCollection(ctx))\n if (!(await read(managerId))) {\n return { reportsToContactId: 'No such contact in this organization' }\n }\n const facet = facetAt(ctx, at)\n if (\n at.contactId &&\n facet &&\n (await contactReportsToLoops(read, at.contactId, managerId, facet.groupId))\n ) {\n return { reportsToContactId: CONTACT_REPORTS_TO_LOOP_REFUSAL }\n }\n return {}\n}\n\n/**\n * The facet a create writes when the body carried a CRM profile field, under\n * the group of the site the write named — filed as `upsertHostContact` files\n * a capture — plus the top-level `companyIds` the company filter queries.\n *\n * A NESTED object rather than dotted paths, because this is a `create()` and\n * there is no other holder's facet on the row to clobber yet.\n * {@link contactCrmUpdateFields} writes the same fields as paths for the\n * opposite reason.\n */\nfunction contactCrmCreateFields(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n siteId: string,\n): Record<string, unknown> {\n const stored = createPayload(crm)\n if (Object.keys(stored).length === 0) return {}\n // The holder's name is the first and last names' when either was given.\n const composed = composeContactName(crm.firstName, crm.lastName)\n if (composed) stored['name'] = composed\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n return {\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: { sources: { api: true }, interactions: [], ...stored },\n },\n ...(crm.companyId ? { companyIds: [crm.companyId] } : {}),\n }\n}\n\n/**\n * The CRM profile as an `update()` payload: one dotted path per field sent,\n * under the facet of the named site's group — `contactFacetPath`, never a\n * nested object, because an `update` REPLACES a map it is handed whole and\n * the nested form would delete every other holder's profile of the person.\n * `null` becomes a field delete, so a PATCH can clear.\n *\n * `companyIds` is the top-level twin of the facets' `companyId`s, kept so\n * `?companyId=` can be one indexed clause. Which ids it keeps is the CRM's\n * one planner's decision (`planContactCompanyLink`, AGL-2613): the old id\n * leaves only when no OTHER holder still files the person under it, an id\n * some other surface put there is left exactly where it was, and the plan\n * also names the companies whose contacts count the write moves — which the\n * caller settles after the contact is written, because they are other\n * documents.\n */\nfunction contactCrmUpdateFields(\n ctx: ApiV1Context,\n data: FirebaseFirestore.DocumentData,\n crm: ContactCrmInput,\n siteId: string,\n): { update: Record<string, unknown>; link: ContactCompanyLinkPlan | null } {\n const update: Record<string, unknown> = {}\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n for (const [field, value] of Object.entries(updatePayload(crm))) {\n update[contactFacetPath(group.groupId, field)] = value\n }\n /*\n * The holder's name follows the first and last names (AGL-3515): either\n * sent recomposes it from what the facet will hold, and both cleared\n * leave it as it stands.\n */\n if (crm.firstName !== undefined || crm.lastName !== undefined) {\n const facet = readContactFacet(data, group.groupId)\n const composed = composeContactName(\n crm.firstName === undefined ? facet.firstName : crm.firstName,\n crm.lastName === undefined ? facet.lastName : crm.lastName,\n )\n if (composed) update[contactFacetPath(group.groupId, 'name')] = composed\n }\n let link: ContactCompanyLinkPlan | null = null\n if (crm.companyId !== undefined) {\n link = planContactCompanyLink(\n readContactCompanyLink(data, group.groupId),\n crm.companyId,\n )\n const mirror = link ? contactCompanyMirrorValue(link) : undefined\n if (mirror !== undefined) update[CONTACT_COMPANY_IDS_FIELD] = mirror\n }\n return { update, link }\n}\n\n/**\n * The holder a contact READ is for, from `?consentSiteId=`: that site's\n * consent group, or `null` for the union view. The parameter the writes take,\n * validated the same way, so a client has one name for \"which site\" on every\n * contact call.\n */\nfunction contactViewGroup(\n ctx: ApiV1Context,\n url: URL,\n): { groupId: string | null } | { response: Response } {\n const siteId = (url.searchParams.get('consentSiteId') ?? '').trim()\n if (!siteId) return { groupId: null }\n if (!orgOwnsHost(ctx, siteId)) {\n return {\n response: ApiErrors.badRequest({\n message: 'Contact filter failed validation',\n code: 'validation_failed',\n fields: { consentSiteId: 'No such site in this organization' },\n headers: ctx.headers,\n }),\n }\n }\n return {\n groupId: consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n .groupId,\n }\n}\n\nconst contactsCollection = (ctx: ApiV1Context) =>\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts')\n\n/** The org's companies, beside its contacts — where a link's count lands (AGL-2613). */\nconst companiesCollection = (ctx: ApiV1Context) =>\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.companies)\n\n/**\n * `POST /v1/contacts` (AGL-2276) — the call that lets an integration own the\n * customer list.\n *\n * ## The audience band\n *\n * `checkContactQuota` is the gate, and it is the SAME one `upsertHostContact`\n * applies to a form capture (AGL-890). Metered plans always create and bill\n * the overage through the `report-usage` rollup, which counts\n * `orgs/{orgId}/contacts` without caring who wrote them — so an API-created\n * contact meters exactly like a captured one, and there is no unbilled door.\n * Free hard-bands at its included count; free cannot reach `/v1` at all\n * without a staff `features.apiAccess` override, which is precisely the shape\n * AGL-2163 found running unbounded, so the gate is here rather than assumed\n * unreachable.\n *\n * Where capture DROPS a refused contact (silently, onto a\n * `counters/contactsDropped` the console alerts on), this refuses out loud\n * with a `403`: a form must never fail a visitor's signup because of billing,\n * and an API call has an operator on the other end who needs to be told.\n *\n * ## Duplicates\n *\n * A contact is unified on its normalized email, so a second create for an\n * address already present is a `409 conflict` naming the existing id rather\n * than a second row. Silently upserting instead would hide a real integration\n * bug — two upstream systems both claiming to own the record — and would make\n * `POST` and `PATCH` the same call.\n *\n * ## Why the claim is taken ABOVE both refusals\n *\n * `createRecord` and `createDataset` check their quota FIRST and claim after,\n * so that a plan refusal never burns a key. That ordering has a hole this one\n * deliberately does not copy, and `api-v1-contact-writes.spec.ts` is what\n * found it: **a create that exactly fills the band cannot be retried.** The\n * first call succeeds and consumes the last slot; the retry — same key, lost\n * response — re-counts, is now AT the band, and gets a `403` instead of the\n * replay `conventions.md` promises (\"if the original succeeded, the same\n * response comes back\"). The integrator is left unable to tell whether the\n * contact exists, which is the exact confusion the key exists to remove. The\n * duplicate check has the same shape, and worse: the retry's own successful\n * write is what makes the email a duplicate, so EVERY retry of a successful\n * create would answer `409 contact_exists`.\n *\n * Claiming first and RELEASING on each refusal gets both properties at once:\n * a settled key replays before any of this is reached, and a refusal gives\n * the key back so the retry that should finally succeed still can. It is the\n * ordering `deleteRecord` already argues for, and it pays the same price —\n * taking-and-releasing on a genuine refusal. AGL-2278 applies it to the two\n * older creates.\n *\n * `visibleTo` is stamped with `ORG_SCOPE_TOKEN`, as capture does. A contact\n * written without it matches no `array-contains-any` and is therefore visible\n * on NO site (AGL-1044) — the API would create data nobody can see, which is\n * worse than refusing.\n */\nasync function createContact(\n request: Request,\n ctx: ApiV1Context,\n): Promise<Response> {\n // Validation stays above the claim, as `createRecord` argues: a\n // deterministic 400 must never take the key at all, so an integrator fixes\n // the payload and retries with the same one.\n const parsed = readContactInput(await readJsonBody(request), {\n partial: false,\n })\n if ('errors' in parsed) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: parsed.errors,\n headers: ctx.headers,\n })\n }\n const {\n email,\n name,\n tags,\n notes,\n marketingConsent,\n consentSiteId,\n consentGroupId,\n crm,\n } = parsed.values\n if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: { consentSiteId: 'No such site in this organization' },\n headers: ctx.headers,\n })\n }\n // Judged against the org's definitions, above the claim like every other\n // deterministic 400: an unknown key is named, never dropped (AGL-2601).\n const custom = parsed.values.custom\n ? readContactCustomInput(\n parsed.values.custom,\n await readOrgContactFieldDefinitions(ctx),\n )\n : null\n if (custom && 'errors' in custom) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: custom.errors,\n headers: ctx.headers,\n })\n }\n // Resolved to the map itself here, above the claim, because the create\n // below is reached through a try block the narrowing above does not\n // survive; an empty map writes no `custom` key at all.\n const customValues = custom && 'values' in custom ? custom.values : {}\n // Still validation, still above the claim: a dangling `companyId` is as\n // deterministic a 400 as a malformed one, and must not burn the key.\n const crmErrors = await contactCrmRefErrors(ctx, crm)\n if (Object.keys(crmErrors).length) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: crmErrors,\n headers: ctx.headers,\n })\n }\n // The canonical name, or the first and last names' for a body with only those.\n const canonicalName = name || composeContactName(crm.firstName, crm.lastName)\n\n const collection = contactsCollection(ctx)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'contacts',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n // Through the org's address index (AGL-2633): an address a merge folded\n // into another record is that record's, and a create on it would mint\n // the duplicate the merge removed.\n const existing = await findContactByEmail(collection, email)\n if (existing) {\n // Released: the conflict clears if that contact is deleted or merged\n // upstream, and the retry that should then succeed must not replay it.\n await claim.release()\n return ApiErrors.conflict({\n message: `A contact with this email already exists (${existing.id}). Update it instead.`,\n code: 'contact_exists',\n headers: ctx.headers,\n })\n }\n\n // Three aggregate reads, the same ones `upsertHostContact` and the\n // monthly rollup take — the band counts companies and deals beside the\n // contacts (AGL-2611). Unconditional, matching `createDataset` — the\n // alternative is a per-plan shape check like `dataStorageEnforcementShape`,\n // and a `count()` costs one read against a write that costs one anyway.\n const quota = await crmRecordsQuotaForOrg(\n ctx.org as never,\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n collection,\n )\n if (!quota.allowed) {\n await claim.release()\n // `contact_quota` is the code this endpoint has always answered with\n // and integrators match on it; the band behind it widened, the code\n // did not. Companies and deals answer `crm_records_quota`.\n return ApiErrors.planRequired({\n message:\n `CRM records limit reached (${quota.included} records across ` +\n 'contacts, companies and deals). Upgrade the plan to add more.',\n code: 'contact_quota',\n headers: ctx.headers,\n })\n }\n\n const id = createResourceUid()\n await collection.doc(id).create({\n email,\n // The search keys travel with the name — the console's contact list\n // searches the whole collection, not the page it fetched.\n ...(canonicalName ? nameSearchFields(canonicalName) : {}),\n tags: tags ?? [],\n ...(notes ? { notes } : {}),\n ...(Object.keys(customValues).length ? { custom: customValues } : {}),\n ...(marketingConsent && consentSiteId\n ? {\n // The named site, or its whole declared consent group when the\n // body echoed the group it showed the person (AGL-3320).\n ...marketingConsentFieldsForGroup(\n apiGrantGroup(ctx, consentSiteId, consentGroupId),\n Date.now(),\n ),\n }\n : {}),\n [CAPTURED_BY_HOST_FIELD]: consentSiteId ? [consentSiteId] : [],\n // A refusal carries no site: it stands against every brand in the\n // account — see `readMarketingBasis` for the asymmetry.\n ...(marketingConsent === false ? { marketingConsent: false } : {}),\n // The CRM profile, when the body carried one — under the named site's\n // facet, which `readContactInput` has already required it to name.\n ...(consentSiteId ? contactCrmCreateFields(ctx, crm, consentSiteId) : {}),\n // `sources.api` — a first-class provenance value beside `form`,\n // `member`, `order` and `booking`, so a merchant reading the console\n // can see which people an integration put there.\n sources: { api: true },\n interactions: [],\n // Nothing is scheduled against a new person, which the console's\n // \"No next activity\" filter asks as `null` (AGL-3321).\n nextTaskAtMs: null,\n // AGL-1044/AGL-1037: without this the contact matches no scoped read.\n visibleTo: [ORG_SCOPE_TOKEN],\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n // What the console's Contacts list searches and filters by (AGL-3321),\n // from the row as it was created, nested facet and all.\n await restampCrmListFieldsAt(collection.doc(id), 'contacts')\n // The address's domain, checked after the response (AGL-3328): a domain\n // that takes no mail makes the new contact read \"Would bounce\".\n scheduleCapturedEmailCheck({ orgId: ctx.orgId, email })\n // The company the body named has one more contact naming it (AGL-2613);\n // a fresh row's plan is the trivial one, and `crmRefErrors` has already\n // required the company to exist.\n if (consentSiteId && crm.companyId) {\n await settleCompanyContactsCounts(\n companiesCollection(ctx),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n crm.companyId,\n ),\n )\n }\n const view = contactView(await collection.doc(id).get())\n // Stored as 200 so a replay is distinguishable from the fresh 201.\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/**\n * `PATCH /v1/contacts/{id}` — the tag/notes edit the console's Contacts page\n * already makes, plus the name and the marketing flag.\n *\n * No `Idempotency-Key`, for `updateRecord`'s reason: the same body twice\n * lands the same state AND returns the same `200`. No quota either — an edit\n * does not grow the audience, and charging a plan refusal for renaming\n * somebody would make a downgraded org unable to correct its own data.\n */\nasync function updateContact(\n request: Request,\n ctx: ApiV1Context,\n contactRef: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const parsed = readContactInput(await readJsonBody(request), { partial: true })\n if ('errors' in parsed) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: parsed.errors,\n headers: ctx.headers,\n })\n }\n const snap = await contactRef.get()\n if (!snap.exists) {\n return ApiErrors.notFound({\n message: 'No such contact',\n headers: ctx.headers,\n })\n }\n\n const {\n name,\n tags,\n notes,\n marketingConsent,\n consentSiteId,\n consentGroupId,\n crm,\n } = parsed.values\n if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: { consentSiteId: 'No such site in this organization' },\n headers: ctx.headers,\n })\n }\n const crmErrors = await contactCrmRefErrors(ctx, crm, {\n contactId: contactRef.id,\n siteId: consentSiteId,\n data: snap.data() ?? {},\n })\n if (Object.keys(crmErrors).length) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: crmErrors,\n headers: ctx.headers,\n })\n }\n const update: Record<string, unknown> = {}\n // A rename must move the search keys with it, or the contact stays findable\n // only by the name they no longer have.\n if (name !== undefined) Object.assign(update, nameSearchFields(name))\n if (tags !== undefined) update.tags = tags\n if (notes !== undefined) update.notes = notes\n if (parsed.values.custom !== undefined) {\n const custom = readContactCustomInput(\n parsed.values.custom,\n await readOrgContactFieldDefinitions(ctx),\n )\n if ('errors' in custom) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: custom.errors,\n headers: ctx.headers,\n })\n }\n /*\n * One dotted path per key, because this is an `update` and an `update`\n * REPLACES a map it is handed whole. `custom` on a PATCH means \"these\n * keys\", the way the console's own save writes only the keys that\n * changed; an integration correcting one value must not have to resend\n * the other nine to keep them (AGL-2601).\n */\n for (const [key, value] of Object.entries(custom.values)) {\n update[`custom.${key}`] = value\n }\n }\n if (marketingConsent === true && consentSiteId) {\n /*\n * A dotted path rather than a nested object, because this is an `update`\n * and an `update` REPLACES a map field it is handed whole. Writing the\n * nested form here would delete every other site's grant — the exact\n * over-application this change exists to end, arriving through the write\n * side instead of the read side.\n */\n // The named site, or its whole declared consent group when the body\n // echoed the group it showed the person (AGL-3320).\n const group = apiGrantGroup(ctx, consentSiteId, consentGroupId)\n for (const hostId of group.hostIds) {\n update[`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}`] = {\n marketingConsent: true,\n // The consent timestamp is the evidence, so it is stamped when\n // consent is GIVEN and left alone when it is withdrawn — an audit\n // needs to know when the person opted in, and clearing it would\n // destroy that record.\n marketingConsentAtMs: Date.now(),\n ...(group.declared\n ? {\n consentGroupId: group.groupId,\n consentGroupName: group.name ?? '',\n }\n : {}),\n }\n }\n } else if (marketingConsent === false) {\n update.marketingConsent = false\n }\n let link: ContactCompanyLinkPlan | null = null\n if (consentSiteId) {\n const crmFields = contactCrmUpdateFields(ctx, snap.data() ?? {}, crm, consentSiteId)\n Object.assign(update, crmFields.update)\n link = crmFields.link\n }\n // An empty body is a no-op answered with the current contact, matching\n // `updateDataset`: a client re-sending an unchanged object should not have\n // to special-case it.\n if (Object.keys(update).length > 0) {\n await contactRef.update({ ...update, updatedAt: Timestamp.now() })\n // What the console's Contacts list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n // The companies the link moved off or onto, counted after the contact\n // is written (AGL-2613); `crmRefErrors` has already required the new\n // company to exist.\n await settleCompanyContactsCounts(companiesCollection(ctx), link)\n }\n // Read back through the site the write named, so what the client sees is\n // the profile it just wrote and not another holder's.\n const groupId = consentSiteId\n ? consentGroupForHost(ctx.org as Record<string, unknown>, consentSiteId)\n .groupId\n : null\n return apiJson(contactView(await contactRef.get(), groupId), {\n headers: ctx.headers,\n })\n}\n\n/**\n * `DELETE /v1/contacts/{id}` — the console's own delete, over the API.\n *\n * Deletes the document whoever else holds it, and keeps every refusal it\n * held — each site's and the unscoped one — in the organization's retained\n * store in the same transaction (AGL-3338), so deleting a person over the\n * API is not a way to add somebody who said no to a list. Then what pointed\n * at the person goes: every other contact's reports-to naming them, in\n * every holder's facet (AGL-3537), and their contact roles on deals\n * (AGL-3521).\n *\n * Takes an `Idempotency-Key` with `deleteRecord`'s exact semantics, and for a\n * sharper reason: an erasure request is the operation most likely to be run\n * from a script on somebody else's deadline, and a retry after a lost\n * response must be able to tell \"already erased\" from \"wrong id\".\n */\nasync function deleteContact(\n request: Request,\n ctx: ApiV1Context,\n contactRef: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'contact-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n // The holders whose facets may name the person as a reports-to (AGL-3537).\n let holders: string[] = []\n const removed = await removeContactKeepingRefusals({\n contactRef,\n decide: (contact) => {\n holders = contactFacetHolders(contact)\n return deleteWholeContact(contact)\n },\n nowMs: Date.now(),\n })\n if (removed.outcome === 'missing') {\n await claim.release()\n return ApiErrors.notFound({\n message: 'No such contact',\n headers: ctx.headers,\n })\n }\n // Nobody reports to a deleted person (AGL-3537)…\n await clearReportsToOf(\n ctx.firestore,\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts'),\n holders,\n contactRef.id,\n )\n // …and they leave every deal's contact roles (AGL-3521).\n await sweepDealContactRoles(\n ctx.firestore,\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.deals),\n contactRef.id,\n null,\n '[api-v1] contacts delete',\n )\n const view = { id: contactRef.id, object: 'contact', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/**\n * `GET /v1/contacts` filters (AGL-2460) — the lookup a sync starts with.\n *\n * The list was ordered by document id with no filter of any kind, which left\n * the first question every CRM, mailing tool or migration asks — \"do I\n * already have this person?\" — answerable only by paging the whole audience.\n * That is not a rounding error. Contacts are ORGANIZATION-wide (AGL-237), so\n * the list is the entire audience band; every page is a BILLED request\n * (`recordApiRequest`) against a documented 120/min per-key ceiling. Finding\n * one address in a 50k audience cost ~500 requests and four minutes, and the\n * integration that gave up and called `POST` instead recovered the id by\n * regex over the `409 contact_exists` sentence — parsing a human message for\n * an identifier, which is not a contract this API should have been offering.\n *\n * Both filters REDUCE work rather than adding it: one indexed lookup replaces\n * a full sweep, so this NARROWS the per-request Firestore read amplification\n * AGL-2414 is about rather than widening it. Neither introduces a new metered\n * dimension — a filtered list is the same billed request the unfiltered one\n * always was, and the customer simply needs far fewer of them.\n *\n * ## `email` runs through the writer's own normalizer\n *\n * `normalizeContactEmail` is the SAME function `createContact` stores\n * through. Matching the raw query string instead would make\n * `?email=Avery@Example.com` answer \"no such contact\" while `POST` with that\n * identical address answers `409 contact_exists` naming its id — two\n * endpoints disagreeing about whether a person exists, which an integrator\n * reasonably reads as our data being corrupt rather than as our having two\n * spellings of one address.\n *\n * A value that cannot normalize is a `400` naming the field, not an empty\n * page. Every stored email is normalized and pattern-valid, so nothing can\n * match a malformed one and an empty page would be perfectly TRUE and\n * perfectly useless: it is indistinguishable from \"we don't have them\", and\n * it sends the caller hunting a missing person instead of a typo.\n *\n * ## `email` is a lookup, not a clause\n *\n * The address goes through the org's address index (AGL-2633) — the same\n * lookup every capture door makes — so an address a merge folded into a\n * record as an alternate finds that record, which is the answer `POST`\n * gives when it refuses the same address as a duplicate. One address names\n * one document, so the page is that document or nothing and never carries\n * a cursor.\n *\n * ## Why the COMBINATION filters after the read\n *\n * An `array-contains` on `tags` and the `orderBy(FieldPath.documentId())`\n * every list applies is already a two-clause query; a third would need a\n * composite index we would have to ship and wait on. The address selects at\n * most one document, and a company selects a short page — testing those\n * rows' tags in memory costs nothing and needs no index. The page can\n * therefore come back empty with `has_more` false, which is the short-page\n * case `conventions.md` already tells clients to expect and which\n * `?channel=online` on orders already produces.\n */\nasync function listContacts(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawEmail = url.searchParams.get('email')\n let email: string | null = null\n if (rawEmail !== null && rawEmail.trim() !== '') {\n email = normalizeContactEmail(rawEmail)\n if (!email) {\n return ApiErrors.badRequest({\n message: 'Contact filter failed validation',\n code: 'validation_failed',\n fields: { email: 'Must be a valid email address' },\n headers: ctx.headers,\n })\n }\n }\n // Trimmed and capped exactly as `readContactInput` stores a tag, so a\n // filter cannot ask for a string the write path could never have written.\n const rawTag = url.searchParams.get('tag')\n const tag =\n rawTag === null ? null : rawTag.trim().slice(0, CONTACT_TAG_MAX) || null\n\n /*\n * The CRM filters (AGL-2606). `companyId` can be a clause: it queries the\n * top-level `companyIds` array, the twin every profile write keeps for\n * exactly this. `lifecycleStage` and `ownerUid` cannot — they live on a\n * facet, and a facet field is not queryable without the holder in the\n * path, which the org-wide read does not have — so both are applied to the\n * page, against the SAME profile the view publishes: the named site's, or\n * the union. A filtered page can therefore come back short, as `?email=`\n * with `?tag=` already does. A stage that is not one of the list is a\n * `400`, because `?lifecycleStage=customers` matching nothing is the\n * plausible empty page the conventions refuse to serve.\n */\n const rawStage = (url.searchParams.get('lifecycleStage') ?? '').trim()\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n return ApiErrors.badRequest({\n message: 'Contact filter failed validation',\n code: 'validation_failed',\n fields: {\n lifecycleStage: `Must be one of: ${CONTACT_LIFECYCLE_STAGES.join(', ')}`,\n },\n headers: ctx.headers,\n })\n }\n const lifecycleStage = rawStage || null\n const ownerUid =\n (url.searchParams.get('ownerUid') ?? '').trim().slice(0, CONTACT_NAME_MAX) ||\n null\n const companyId =\n (url.searchParams.get('companyId') ?? '').trim().slice(0, CRM_ID_MAX) || null\n const group = contactViewGroup(ctx, url)\n if ('response' in group) return group.response\n\n // ONE selector, the most selective that was given: the unique email, then\n // the company, then the tag. Every other filter is checked on the page.\n let docs: FirebaseFirestore.DocumentSnapshot[]\n let nextCursor: string | null = null\n let clause: 'email' | 'companyId' | 'tag' | null = null\n if (email) {\n const found = await findContactByEmail(collection, email)\n docs = found ? [found] : []\n clause = 'email'\n } else {\n let query: FirebaseFirestore.Query = collection\n if (companyId) {\n query = query.where('companyIds', 'array-contains', companyId)\n clause = 'companyId'\n } else if (tag) {\n query = query.where('tags', 'array-contains', tag)\n clause = 'tag'\n }\n ;({ docs, nextCursor } = await paginate(query, url))\n }\n const matched = docs.filter((doc) => {\n const data = doc.data() ?? {}\n if (tag && clause !== 'tag') {\n if (!Array.isArray(data.tags) || !data.tags.includes(tag)) return false\n }\n if (companyId && clause !== 'companyId') {\n if (!Array.isArray(data.companyIds) || !data.companyIds.includes(companyId)) {\n return false\n }\n }\n if (lifecycleStage || ownerUid) {\n const profile = contactCrmProfile(data, group.groupId)\n if (lifecycleStage && profile.lifecycleStage !== lifecycleStage) return false\n if (ownerUid && profile.ownerUid !== ownerUid) return false\n }\n return true\n })\n return listResponse(\n matched.map((doc) => contactView(doc, group.groupId)),\n nextCursor,\n ctx.headers,\n )\n}\n\nexport async function handleContacts(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = contactsCollection(ctx)\n const [, contactId] = segments\n\n if (!contactId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'contacts:read')\n if (denied) return denied\n return listContacts(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n return createContact(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const contactRef = collection.doc(contactId)\n\n // `/v1/contacts/{id}/merge` (AGL-2625): the one action on a contact that\n // is not a verb on the document itself.\n if (segments[2] === 'merge' && segments.length === 3) {\n if (request.method !== 'POST') {\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'POST' },\n })\n }\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n const group = contactViewGroup(ctx, url)\n if ('response' in group) return group.response\n return mergeContactRoute(request, ctx, contactRef, (snap) =>\n contactView(snap, group.groupId),\n )\n }\n\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'contacts:read')\n if (denied) return denied\n const group = contactViewGroup(ctx, url)\n if ('response' in group) return group.response\n const snap = await contactRef.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such contact', headers: ctx.headers })\n }\n return apiJson(contactView(snap, group.groupId), { headers: ctx.headers })\n }\n if (request.method === 'PATCH') {\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n return updateContact(request, ctx, contactRef)\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n return deleteContact(request, ctx, contactRef)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, PATCH, DELETE' },\n })\n}\n"],"names":["nameSearchFields","CAPTURED_BY_HOST_FIELD","composeContactName","CONTACT_BIRTHDATE_REFUSAL","CONTACT_COMPANY_IDS_FIELD","CONTACT_EXTRA_PHONE_FIELDS","CONTACT_FACETS_FIELD","CONTACT_FIELDS_MAX_PER_ORG","CONTACT_LIFECYCLE_STAGES","CONTACT_NAME_PART_MAX","consentGroupForHost","contactFacetPath","createResourceUid","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_MAX","CRM_SALUTATION_PICKLIST","isContactLifecycleStage","judgeCrmPicklistValue","MARKETING_CONSENT_BY_HOST_FIELD","marketingConsentFieldsForGroup","marketingConsentHostIds","normalizeAddress","normalizeContactBirthdate","normalizeContactEmail","normalizeCrmMediaIds","normalizePhone","ORG_SCOPE_TOKEN","planContactCompanyLink","readContactCompanyLink","readContactCustomInput","readContactFacet","soloConsentGroup","ApiErrors","apiJson","contactCompanyMirrorValue","crmRecordsQuotaForOrg","listResponse","restampCrmListFieldsAt","settleCompanyContactsCounts","claimWrite","orgOwnsHost","paginate","readJsonBody","requireScope","serialize","scheduleCapturedEmailCheck","findContactByEmail","deleteWholeContact","removeContactKeepingRefusals","FieldPath","Timestamp","mergeContactRoute","sweepDealContactRoles","cachedContactReader","clearReportsToOf","CONTACT_REPORTS_TO_LOOP_REFUSAL","CONTACT_REPORTS_TO_SELF_REFUSAL","contactReportsToLoops","readCrmPicklist","CRM_ID_MAX","CRM_LABEL_MAX","createPayload","crmRefErrors","memberError","readChoice","readOptionalText","readOrgLeadSourcePicklist","readRefId","updatePayload","resolveCrmPicklistWrite","CONTACT_CRM_FIELDS","contactFacetHolders","data","facets","Array","isArray","Object","keys","sort","contactCrmProfile","groupId","profile","phone","jobTitle","companyId","address","ownerUid","lifecycleStage","leadSource","mediaIds","salutation","firstName","lastName","department","mobilePhone","homePhone","otherPhone","fax","birthdate","assistantName","assistantPhone","reportsToContactId","otherAddress","doNotCall","holder","facet","field","value","held","length","undefined","contactView","doc","id","object","email","name","tags","notes","companyIds","filter","alternateEmails","marketingConsent","consentSites","sources","custom","created","createdAt","updated","updatedAt","readOrgContactFieldDefinitions","ctx","snapshot","firestore","collection","orgId","contactFields","orderBy","documentId","limit","get","docs","map","CONTACT_NAME_MAX","CONTACT_NOTES_MAX","CONTACT_TAGS_MAX","CONTACT_TAG_MAX","CONTACT_WRITABLE","apiGrantGroup","siteId","echoedGroupId","group","org","declared","readContactInput","body","partial","errors","values","crm","allowed","Set","key","has","String","trim","slice","tag","consentSiteId","consentGroupId","normalized","stage","part","text","reportsTo","writesCrm","some","contactCrmRefErrors","at","contactId","facetAt","owner","refs","Promise","all","contactLeadSourceErrors","contactSalutationError","contactReportsToError","current","resolved","ok","error","write","judged","managerId","read","contactsCollection","contactCrmCreateFields","stored","composed","api","interactions","contactCrmUpdateFields","update","entries","link","mirror","contactViewGroup","url","searchParams","response","badRequest","message","code","fields","headers","companiesCollection","companies","createContact","request","parsed","customValues","crmErrors","canonicalName","claimed","replay","claim","existing","release","conflict","quota","planRequired","included","create","Date","now","nextTaskAtMs","visibleTo","heldElsewhere","view","record","status","updateContact","contactRef","snap","exists","notFound","assign","hostId","hostIds","marketingConsentAtMs","consentGroupName","crmFields","deleteContact","holders","removed","decide","contact","nowMs","outcome","deals","deleted","listContacts","rawEmail","rawTag","rawStage","join","nextCursor","clause","found","query","where","matched","includes","handleContacts","segments","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/contacts.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 { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n type AglynPostalAddress,\n CAPTURED_BY_HOST_FIELD,\n composeContactName,\n CONTACT_BIRTHDATE_REFUSAL,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_EXTRA_PHONE_FIELDS,\n CONTACT_FACETS_FIELD,\n ORG_CONTACT_HOLDER_ID,\n CONTACT_FIELDS_MAX_PER_ORG,\n CONTACT_LIFECYCLE_STAGES,\n CONTACT_NAME_PART_MAX,\n type ConsentGroup,\n consentGroupForHost,\n crmDefaultScopeOf,\n crmScopeTokens,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n contactFacetPath,\n type ContactFieldDefinition,\n type ContactLifecycleStage,\n createResourceUid,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_MAX,\n CRM_SALUTATION_PICKLIST,\n isContactLifecycleStage,\n judgeCrmPicklistValue,\n MARKETING_CONSENT_BY_HOST_FIELD,\n marketingConsentFieldsForGroup,\n marketingConsentHostIds,\n normalizeAddress,\n normalizeContactBirthdate,\n normalizeContactEmail,\n normalizeCrmMediaIds,\n normalizePhone,\n ORG_SCOPE_TOKEN,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactCustomInput,\n readContactFacet,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n ApiErrors,\n apiJson,\n contactCompanyMirrorValue,\n crmRecordsQuotaForOrg,\n listResponse,\n restampCrmListFieldsAt,\n settleCompanyContactsCounts,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n claimWrite,\n orgOwnsHost,\n paginate,\n readJsonBody,\n requireScope,\n serialize,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport { scheduleCapturedEmailCheck } from '@aglyn/tenant-data-admin/server/capture-email-check'\n// The leaf, not the barrel: the console's API specs substitute the barrel\n// wholesale, and the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\n// The leaf for the same reason: the refusals a deleted contact held must be\n// kept by the real store under a spec's barrel (AGL-3338).\nimport {\n deleteWholeContact,\n removeContactKeepingRefusals,\n} from '@aglyn/tenant-data-admin/server/retained-refusals'\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\nimport { mergeContactRoute } from './contacts-merge'\nimport { sweepDealContactRoles } from '../deal-contact-roles'\nimport {\n cachedContactReader,\n clearReportsToOf,\n CONTACT_REPORTS_TO_LOOP_REFUSAL,\n CONTACT_REPORTS_TO_SELF_REFUSAL,\n contactReportsToLoops,\n} from '../contact-reports-to'\nimport { readCrmPicklist } from '../read-picklist'\nimport {\n CRM_ID_MAX,\n CRM_LABEL_MAX,\n createPayload,\n crmRefErrors,\n memberError,\n readChoice,\n readOptionalText,\n readOrgLeadSourcePicklist,\n readRefId,\n updatePayload,\n} from './crm-shared'\nimport { resolveCrmPicklistWrite } from '../read-picklist'\n\n/**\n * `/v1/contacts` (AGL-618, AGL-2276, AGL-2606): the organization's people —\n * list, read, create, update, delete, and `POST /v1/contacts/{id}/merge` —\n * served by the CRM, which keeps them. The pipeline in front of it (the key,\n * the plan's API access, the quota, the rate limit, the envelope) is the\n * console router's; the CRM suite's plan refusal is declared with the\n * registration (`declarations.console-server.ts`).\n */\n\n// ── Contacts (read) ─────────────────────────────────────────────────────────\n\n/**\n * The contact object as published.\n *\n * `notes` and `marketingConsent` are here because `PATCH` writes them\n * (AGL-2276). A projection that omits a field the same resource accepts is\n * the shape that has shipped broken before: the client writes, reads back,\n * sees nothing, and cannot tell a dropped write from a narrow view. Every\n * writable field appears here, and `contact-writes.spec.ts` asserts that as a\n * property of the pair rather than field by field.\n *\n * `email`, `sources` and `interactions` are read-only and stay that way —\n * `email` is the dedupe key the whole CRM unifies on, and the other two are\n * provenance. `interactions` is not published at all; the console's timeline\n * is not part of the API contract.\n */\n/**\n * The CRM profile a contact carries (AGL-2606): the six fields a sales team\n * keeps on a person, read from the FACETS because that is where the console\n * keeps them — per holder, under `facets.{groupId}` (`ContactFacet`), so two\n * unrelated businesses sharing one row never read each other's knowledge of\n * the person.\n *\n * An API key is an organization credential, and every facet on the row is\n * the organization's own; so with no site named the profile is the UNION of\n * the holders — each field from the first holder, in stable id order, that\n * has set it. That is the right read for the caller a key represents (an\n * integration acting for the account) and the only one that does not force\n * every read to name a site. Naming one (`?consentSiteId=`) reads that\n * site's group alone, which is what a per-brand sync wants and what a PATCH\n * that just wrote through that site reads back.\n *\n * `null` for every unset field rather than an absent key, so a client can\n * tell \"no phone\" from \"this API does not publish phones\".\n */\nconst CONTACT_CRM_FIELDS = [\n 'phone',\n 'jobTitle',\n 'companyId',\n 'address',\n 'ownerUid',\n 'lifecycleStage',\n // The holder's Lead source, one of the org's picklist values (AGL-3511).\n 'leadSource',\n // Files are a facet field like the rest (AGL-2662): an agency running two\n // client brands has one contact document between them, and a contract one\n // client filed is not the other client's to read.\n 'mediaIds',\n // Salesforce's standard contact fields (AGL-3515), per holder like the rest.\n 'salutation',\n 'firstName',\n 'lastName',\n 'department',\n 'mobilePhone',\n 'homePhone',\n 'otherPhone',\n 'fax',\n 'birthdate',\n 'assistantName',\n 'assistantPhone',\n 'reportsToContactId',\n 'otherAddress',\n 'doNotCall',\n] as const\n\ntype ContactCrmField = (typeof CONTACT_CRM_FIELDS)[number]\n\n/** The groups holding a facet on this row, in stable order. */\nfunction contactFacetHolders(data: FirebaseFirestore.DocumentData): string[] {\n const facets = data[CONTACT_FACETS_FIELD]\n return facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets).sort()\n : []\n}\n\nfunction contactCrmProfile(\n data: FirebaseFirestore.DocumentData,\n groupId: string | null,\n): Record<ContactCrmField, unknown> {\n const profile: Record<ContactCrmField, unknown> = {\n phone: null,\n jobTitle: null,\n companyId: null,\n address: null,\n ownerUid: null,\n lifecycleStage: null,\n leadSource: null,\n // An empty ARRAY rather than null, so a client can index it without a\n // guard — the same shape `custom` publishes for the same reason.\n mediaIds: [],\n salutation: null,\n firstName: null,\n lastName: null,\n department: null,\n mobilePhone: null,\n homePhone: null,\n otherPhone: null,\n fax: null,\n birthdate: null,\n assistantName: null,\n assistantPhone: null,\n reportsToContactId: null,\n otherAddress: null,\n // A flag reads `false` rather than null: unset IS \"may be called\".\n doNotCall: false,\n }\n for (const holder of groupId ? [groupId] : contactFacetHolders(data)) {\n const facet = readContactFacet(data, holder)\n for (const field of CONTACT_CRM_FIELDS) {\n const value = facet[field]\n if (field === 'mediaIds') {\n // The union of every holder's attachments when no site was named,\n // and one holder's when one was — the same rule the scalar fields\n // follow, spelled apart because a list merges rather than wins.\n const held = profile[field] as string[]\n if (!held.length) profile[field] = normalizeCrmMediaIds(value)\n continue\n }\n if (field === 'doNotCall') {\n if (value === true) profile[field] = true\n continue\n }\n if (profile[field] === null && value !== undefined && value !== null) {\n profile[field] = value\n }\n }\n }\n return profile\n}\n\n/**\n * @param groupId - the holder whose CRM profile to publish, or `null` for\n * the union — see {@link contactCrmProfile}.\n */\nfunction contactView(\n doc: FirebaseFirestore.DocumentSnapshot,\n groupId: string | null = null,\n) {\n const data = doc.data() ?? {}\n return {\n id: doc.id,\n object: 'contact',\n // Blank in the store so a sort lists them; no address to an integrator.\n email: data.email || null,\n name: data.name ?? null,\n tags: data.tags ?? [],\n notes: data.notes ?? null,\n ...contactCrmProfile(data, groupId),\n // Every company any holder has filed the person under — the top-level\n // twin of the facets' `companyId`, and what `?companyId=` queries.\n companyIds: Array.isArray(data.companyIds)\n ? data.companyIds.filter((id: unknown) => typeof id === 'string')\n : [],\n // The other addresses a merge folded into this record (AGL-2625) —\n // identity like `email`, and read-only for the same reason.\n alternateEmails: Array.isArray(data.alternateEmails)\n ? data.alternateEmails.filter((email: unknown) => typeof email === 'string')\n : [],\n /*\n * TRUE means \"some site may mail this person\", and `consentSites` says\n * which. A single boolean is what the org-wide model published, and it is\n * exactly the claim that turned out to be wrong: an agency's key read\n * `true` and could not tell which of its brands the person had agreed to\n * hear from. Both are published so a client can act on either.\n */\n marketingConsent:\n data.marketingConsent === false\n ? false\n : marketingConsentHostIds(data).length > 0,\n consentSites: marketingConsentHostIds(data),\n sources: data.sources ? Object.keys(data.sources) : [],\n /*\n * The org's custom field values, keyed by field key (AGL-2601). The same\n * top-level map `tags` and `notes` beside it are read from — this resource\n * is the ORGANIZATION's view, and its writes land there. An empty object\n * rather than `null`, so a client can index it without a guard.\n */\n custom:\n data.custom && typeof data.custom === 'object' && !Array.isArray(data.custom)\n ? (data.custom as Record<string, ContactCustomValue>)\n : {},\n created: serialize(data.createdAt) ?? null,\n updated: serialize(data.updatedAt) ?? null,\n }\n}\n\n/**\n * The org's custom field definitions, retired ones included, for the API's\n * `custom` validation (AGL-2601).\n *\n * One bounded read of a small collection, paid only when a body carries\n * `custom` — the reader is called from the two writers, never from the view.\n * Retired definitions come back too because `readContactCustomInput` refuses\n * a write under one BY NAME, which is a better answer than \"no such field\"\n * for a key the integration wrote last month.\n */\nasync function readOrgContactFieldDefinitions(\n ctx: ApiV1Context,\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await ctx.firestore\n .collection('orgs')\n .doc(ctx.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .orderBy(FieldPath.documentId())\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n return snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition)\n}\n\nconst CONTACT_NAME_MAX = 120\nconst CONTACT_NOTES_MAX = 2000\nconst CONTACT_TAGS_MAX = 50\nconst CONTACT_TAG_MAX = 60\n\n/** Fields a client may send. Anything else is named, never silently dropped. */\nconst CONTACT_WRITABLE = [\n 'name',\n 'tags',\n 'notes',\n 'marketingConsent',\n 'consentSiteId',\n 'consentGroupId',\n 'custom',\n] as const\n\n/**\n * The sites an API opt-in is recorded for (AGL-3320): the named site's whole\n * consent group only when the body ECHOED that group's id, and the site\n * alone otherwise.\n *\n * An integration's own signup form is a capture surface this platform never\n * sees, so it cannot prove which sentence it showed the way a Form block\n * does. Echoing the group's id is the integration saying it showed the\n * group's name; an opt-in that says nothing about the group, or names one\n * the site is no longer in, is the one site's — narrow, which is the safe\n * way to be wrong, and visible in the response's `consentSites`.\n */\nfunction apiGrantGroup(\n ctx: ApiV1Context,\n siteId: string,\n echoedGroupId: string | undefined,\n): ConsentGroup {\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n return group.declared && echoedGroupId === group.groupId\n ? group\n : soloConsentGroup(siteId)\n}\n\n/**\n * Validate the writable half of a contact (AGL-2276). `partial` separates\n * PATCH from POST exactly as `readDatasetInput` does: a create must carry an\n * `email`, an update may send any one field alone and leaves the rest alone.\n *\n * Unknown keys are REFUSED rather than dropped, following\n * `updateFormSubmission` and not `createRecord`: a record has a dataset model\n * that defines what exists, and a contact does not, so a silent drop would\n * read as \"we stored your correction\" when nothing was stored. `email` gets\n * its own message on PATCH because sending it is the honest mistake an\n * integrator makes first — it is the field their own system keys on.\n */\n/**\n * The CRM profile fields a contact write may carry (AGL-2606), each `null`\n * to clear. A `type` rather than an `interface` so it is a\n * `Record<string, unknown>` to the payload helpers without a cast.\n */\ntype ContactCrmInput = {\n phone?: string | null\n jobTitle?: string | null\n companyId?: string | null\n address?: AglynPostalAddress | null\n ownerUid?: string | null\n lifecycleStage?: ContactLifecycleStage | null\n /** A value of the org's Lead source picklist, judged by the writer (AGL-3511). */\n leadSource?: string | null\n /** Org-library files attached by this holder (AGL-2662), by media id. */\n mediaIds?: string[]\n /* Salesforce's standard contact fields (AGL-3515). */\n salutation?: string | null\n firstName?: string | null\n lastName?: string | null\n department?: string | null\n mobilePhone?: string | null\n homePhone?: string | null\n otherPhone?: string | null\n fax?: string | null\n birthdate?: string | null\n assistantName?: string | null\n assistantPhone?: string | null\n reportsToContactId?: string | null\n otherAddress?: AglynPostalAddress | null\n /** Stored only as `true`; `false` reads as `null`, which clears. */\n doNotCall?: true | null\n}\n\nfunction readContactInput(\n body: Record<string, unknown>,\n { partial }: { partial: boolean },\n):\n | {\n values: {\n email?: string\n name?: string\n tags?: string[]\n notes?: string\n marketingConsent?: boolean\n consentSiteId?: string\n /** The consent group the opt-in was disclosed under (AGL-3320). */\n consentGroupId?: string\n /**\n * The `custom` map as SENT, shape-checked only. Its keys and values\n * are judged against the org's definitions by the writer, which is\n * the side holding a Firestore handle (AGL-2601).\n */\n custom?: Record<string, unknown>\n crm: ContactCrmInput\n }\n }\n | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n const values: {\n email?: string\n name?: string\n tags?: string[]\n notes?: string\n marketingConsent?: boolean\n consentSiteId?: string\n consentGroupId?: string\n custom?: Record<string, unknown>\n crm: ContactCrmInput\n } = { crm: {} }\n\n const allowed = new Set<string>([\n ...CONTACT_WRITABLE,\n ...CONTACT_CRM_FIELDS,\n ...(partial ? [] : ['email']),\n ])\n for (const key of Object.keys(body)) {\n if (allowed.has(key)) continue\n errors[key] =\n key === 'email'\n ? 'Not writable — a contact is identified by its email'\n : key === 'sources' || key === 'interactions'\n ? 'Not writable — set by the capture point that recorded it'\n : 'Not writable on a contact'\n }\n\n if (!partial) {\n // The SAME normalizer every capture point uses. Re-implementing the\n // check here would let the API accept an address `upsertHostContact`\n // would reject, and the two would then disagree about who is a duplicate.\n // Optional (AGL-4122): an address that was sent still has to be one, and\n // a person with none is named below instead.\n if (body.email !== undefined && body.email !== null && body.email !== '') {\n const email = normalizeContactEmail(body.email)\n if (!email) errors.email = 'A valid email address is required'\n else values.email = email\n }\n }\n\n if (body.name !== undefined) {\n const name = String(body.name ?? '').trim().slice(0, CONTACT_NAME_MAX)\n if (name) values.name = name\n else errors.name = 'Must not be empty'\n }\n\n if (body.tags !== undefined) {\n if (!Array.isArray(body.tags)) {\n errors.tags = 'Must be an array of strings'\n } else {\n // An EMPTY array is legal and means \"clear the tags\" — the console's\n // own tag editor can empty the field, and an API that could only ever\n // add tags would leave an integration unable to undo its own mistake.\n values.tags = (body.tags as unknown[])\n .map((tag) => String(tag).trim().slice(0, CONTACT_TAG_MAX))\n .filter((tag) => tag.length > 0)\n .slice(0, CONTACT_TAGS_MAX)\n }\n }\n\n if (body.notes !== undefined) {\n values.notes = String(body.notes ?? '').slice(0, CONTACT_NOTES_MAX)\n }\n\n if (body.consentSiteId !== undefined) {\n const siteId = String(body.consentSiteId ?? '').trim()\n if (!siteId) errors.consentSiteId = 'Must name a site'\n else values.consentSiteId = siteId\n }\n\n if (body.consentGroupId !== undefined) {\n const groupId =\n typeof body.consentGroupId === 'string' ? body.consentGroupId.trim() : ''\n if (!groupId) errors.consentGroupId = 'Must name a consent group'\n else values.consentGroupId = groupId\n }\n\n if (body.marketingConsent !== undefined) {\n if (typeof body.marketingConsent !== 'boolean') {\n errors.marketingConsent = 'Must be true or false'\n } else {\n values.marketingConsent = body.marketingConsent\n }\n }\n\n if (body.custom !== undefined) {\n if (!body.custom || typeof body.custom !== 'object' || Array.isArray(body.custom)) {\n errors.custom = 'Must be an object of field values keyed by field key'\n } else {\n values.custom = body.custom as Record<string, unknown>\n }\n }\n\n /*\n * The CRM profile (AGL-2606). Each value runs through the normalizer the\n * console writes with — `normalizePhone`, `normalizeAddress`, the lifecycle\n * list — so the API cannot store a phone the console would refuse, or an\n * address of empty strings the console would read as \"has an address\". A\n * value that does not survive names the field rather than storing the raw\n * string: a half-normalized phone is the unusable number `normalizePhone`\n * exists to end. The readers are `crm-shared`'s, so a contact and a company\n * bound their text the same way. `ownerUid` and `companyId` are checked for\n * existence by the caller, after this synchronous pass, so a body already\n * refused never spends the reads.\n */\n const crm: ContactCrmInput = {}\n const phone = readOptionalText(body, 'phone', CRM_LABEL_MAX, errors)\n if (phone === null) {\n crm.phone = null\n } else if (phone !== undefined) {\n const normalized = normalizePhone(phone)\n if (normalized) crm.phone = normalized\n else errors.phone = 'Must be a phone number with a country code, like +15125550123'\n }\n const jobTitle = readOptionalText(body, 'jobTitle', CONTACT_NAME_MAX, errors)\n if (jobTitle !== undefined) crm.jobTitle = jobTitle\n const companyId = readRefId(body, 'companyId', errors)\n if (companyId !== undefined) crm.companyId = companyId\n if (body.address !== undefined) {\n if (body.address === null) {\n crm.address = null\n } else if (typeof body.address !== 'object' || Array.isArray(body.address)) {\n errors.address = 'Must be an address object'\n } else {\n // A blank address normalizes to `null`, which clears.\n crm.address = normalizeAddress(body.address as AglynPostalAddress)\n }\n }\n const ownerUid = readOptionalText(body, 'ownerUid', CONTACT_NAME_MAX, errors)\n if (ownerUid !== undefined) crm.ownerUid = ownerUid\n // Shape only here; the org's list judges the value in `contactCrmRefErrors`.\n const leadSource = readOptionalText(body, 'leadSource', CRM_LABEL_MAX, errors)\n if (leadSource !== undefined) crm.leadSource = leadSource\n if (body.mediaIds !== undefined) {\n if (!Array.isArray(body.mediaIds)) {\n errors.mediaIds = 'Must be an array of media ids'\n } else if (body.mediaIds.length > CRM_MEDIA_IDS_MAX) {\n errors.mediaIds = `At most ${CRM_MEDIA_IDS_MAX} files may be attached`\n } else {\n // The SAME normalizer the console card writes through. An empty array\n // is legal and clears this holder's attachments.\n crm.mediaIds = normalizeCrmMediaIds(body.mediaIds)\n }\n }\n if (body.lifecycleStage === null) {\n crm.lifecycleStage = null\n } else {\n const stage = readChoice(body, 'lifecycleStage', CONTACT_LIFECYCLE_STAGES, errors)\n if (stage) crm.lifecycleStage = stage\n }\n /*\n * Salesforce's standard contact fields (AGL-3515), through the normalizers\n * the console's profile route uses. The salutation and the reports-to are\n * judged after this pass, against the org's list and its contacts.\n */\n const salutation = readOptionalText(body, 'salutation', CRM_LABEL_MAX, errors)\n if (salutation !== undefined) crm.salutation = salutation\n for (const key of ['firstName', 'lastName'] as const) {\n const part = readOptionalText(body, key, CONTACT_NAME_PART_MAX, errors)\n if (part !== undefined) crm[key] = part\n }\n for (const key of ['department', 'assistantName'] as const) {\n const text = readOptionalText(body, key, CONTACT_NAME_MAX, errors)\n if (text !== undefined) crm[key] = text\n }\n for (const key of CONTACT_EXTRA_PHONE_FIELDS) {\n const text = readOptionalText(body, key, CRM_LABEL_MAX, errors)\n if (text === null) {\n crm[key] = null\n } else if (text !== undefined) {\n const normalized = normalizePhone(text)\n if (normalized) crm[key] = normalized\n else errors[key] = 'Must be a phone number with a country code, like +15125550123'\n }\n }\n const birthdate = readOptionalText(body, 'birthdate', 32, errors)\n if (birthdate === null) {\n crm.birthdate = null\n } else if (birthdate !== undefined) {\n const normalized = normalizeContactBirthdate(birthdate)\n if (normalized) crm.birthdate = normalized\n else errors.birthdate = CONTACT_BIRTHDATE_REFUSAL\n }\n const reportsTo = readRefId(body, 'reportsToContactId', errors)\n if (reportsTo !== undefined) crm.reportsToContactId = reportsTo\n if (body.otherAddress !== undefined) {\n if (body.otherAddress === null) {\n crm.otherAddress = null\n } else if (typeof body.otherAddress !== 'object' || Array.isArray(body.otherAddress)) {\n errors.otherAddress = 'Must be an address object'\n } else {\n crm.otherAddress = normalizeAddress(body.otherAddress as AglynPostalAddress)\n }\n }\n if (body.doNotCall !== undefined) {\n if (body.doNotCall === null || body.doNotCall === false) crm.doNotCall = null\n else if (body.doNotCall === true) crm.doNotCall = true\n else errors.doNotCall = 'Must be true or false'\n }\n values.crm = crm\n // Whether the body MEANT to write a profile — judged by the keys it sent,\n // not by which of them parsed, so a body whose every profile value was\n // refused is told about those values and not also that its site was\n // unwelcome.\n const writesCrm = CONTACT_CRM_FIELDS.some((field) => body[field] !== undefined)\n\n /*\n * AN OPT-IN MUST NAME THE SITE IT WAS GIVEN TO; A REFUSAL MUST NOT.\n *\n * An API key belongs to an ORGANIZATION, and an organization is not a\n * controller — an agency's key reaches every client brand it runs. So an\n * integration asserting that somebody opted in has to say which brand they\n * opted in to, exactly as a form does by being served from one site. There\n * is no safe default: picking the org's only site works until the org has\n * two, and picking none is the org-wide grant this field exists to stop.\n *\n * A refusal is the mirror image and is refused a site on purpose. It\n * applies to every brand in the account, which is what `readMarketingBasis`\n * does with an unscoped `false`, and accepting a site alongside it would\n * imply a per-brand opt-out this endpoint does not write.\n */\n if (values.marketingConsent === true && !values.consentSiteId) {\n errors.consentSiteId =\n 'Required with marketingConsent: true — name the site the person opted in to'\n }\n /*\n * A CRM PROFILE FIELD NAMES THE SITE TOO (AGL-2606), for the facet's\n * reason: the profile is one holder's knowledge of the person and lives\n * under that holder's group, so a write has to say whose. The same\n * parameter, because it is the same question — which of the organization's\n * sites is this write made on behalf of — and an integrator should not\n * learn two names for it. Beside a refusal the site is accepted, since the\n * refusal still applies to every site and the site now names the facet.\n */\n if (writesCrm && !values.consentSiteId && partial) {\n errors.consentSiteId =\n 'Required with a CRM profile field — name the site whose profile of this person to write'\n }\n /*\n * WHO THIS IS (AGL-4122). A contact needs no address, so a create needs\n * something else to call them by. With no site and no address the profile is\n * the organization's own, and nothing here narrows that.\n */\n if (\n !partial &&\n !values.email &&\n !values.name &&\n !values.crm.phone &&\n !values.crm.mobilePhone &&\n !values.crm.firstName &&\n !values.crm.lastName &&\n !errors.email\n ) {\n errors.email = 'Send an email, a name or a phone number'\n }\n if (values.marketingConsent === false && values.consentSiteId && !writesCrm) {\n errors.consentSiteId =\n 'Not accepted with marketingConsent: false — a refusal applies to every site'\n }\n if (values.consentSiteId && values.marketingConsent === undefined && !writesCrm) {\n errors.consentSiteId =\n 'Only accepted alongside marketingConsent or a CRM profile field'\n }\n /*\n * THE GROUP IS ECHOED ONLY BESIDE AN OPT-IN (AGL-3320). It says which\n * disclosure the person saw, so it widens a grant to the site's consent\n * group and means nothing anywhere else — least of all beside a refusal,\n * which already stands against every site.\n */\n if (values.consentGroupId && values.marketingConsent !== true) {\n errors.consentGroupId =\n 'Only accepted with marketingConsent: true — name the consent group the person was shown'\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\n\n/**\n * The two references a contact's CRM profile can carry, checked for\n * existence — the SAME checks a deal or a task makes on its own `ownerUid`\n * and `companyId`, so a contact cannot point at a company `/v1/deals` would\n * refuse. Run after the synchronous grammar, so a body already refused never\n * spends the reads.\n */\nasync function contactCrmRefErrors(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n at: ContactWriteAt = { contactId: null },\n): Promise<Record<string, string>> {\n const [owner, refs, leadSource, salutation, reportsTo] = await Promise.all([\n memberError(ctx, 'ownerUid', crm.ownerUid),\n crmRefErrors(ctx, { companyId: crm.companyId ?? undefined }),\n // The holder's current lead source is kept even when the list no longer offers it.\n contactLeadSourceErrors(ctx, crm, facetAt(ctx, at)?.facet.leadSource),\n contactSalutationError(ctx, crm, at),\n contactReportsToError(ctx, crm, at),\n ])\n return { ...owner, ...refs, ...leadSource, ...salutation, ...reportsTo }\n}\n\n/**\n * The lead source a contact write stores (AGL-3511), judged against the\n * org's Lead source list exactly as a lead's is: an active value stores the\n * list's spelling, the holder's current value is always kept, and anything\n * else is refused naming what the list allows. Rewrites `crm.leadSource` to\n * the label the list spells. A contact takes no default — the list's default\n * is for a new LEAD.\n */\nasync function contactLeadSourceErrors(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n current: unknown,\n): Promise<Record<string, string>> {\n if (typeof crm.leadSource !== 'string') return {}\n const resolved = resolveCrmPicklistWrite(\n CRM_LEAD_SOURCE_PICKLIST,\n await readOrgLeadSourcePicklist(ctx),\n crm.leadSource,\n { current, created: false },\n )\n if (resolved.ok === false) return { leadSource: resolved.error }\n crm.leadSource = resolved.write ?? null\n return {}\n}\n\n/** The contact a write lands on — `null` for a create — and the site naming its facet. */\ninterface ContactWriteAt {\n contactId: string | null\n siteId?: string\n data?: FirebaseFirestore.DocumentData\n}\n\n/** The facet a write at `at` reads its current values from. */\nfunction facetAt(ctx: ApiV1Context, at: ContactWriteAt) {\n if (!at.data || !at.siteId) return null\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, at.siteId)\n return { groupId: group.groupId, facet: readContactFacet(at.data, group.groupId) }\n}\n\n/**\n * The salutation, judged against the org's list (AGL-3515) the way the\n * console's route judges it: an active value stores the list's spelling,\n * the value the contact already holds is kept, anything else is named with\n * the values allowed. Rewrites `crm.salutation` to the stored spelling.\n */\nasync function contactSalutationError(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n at: ContactWriteAt,\n): Promise<Record<string, string>> {\n if (!crm.salutation) return {}\n const judged = judgeCrmPicklistValue(\n CRM_SALUTATION_PICKLIST,\n await readCrmPicklist(ctx.firestore, ctx.orgId, CRM_SALUTATION_PICKLIST),\n crm.salutation,\n facetAt(ctx, at)?.facet.salutation,\n )\n if (judged.ok === false) return { salutation: judged.error }\n crm.salutation = judged.value ?? null\n return {}\n}\n\n/**\n * The reports-to (AGL-3515): a contact of this organization, never the\n * contact itself, and never one whose own chain — through the named site's\n * facets — leads back to it.\n */\nasync function contactReportsToError(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n at: ContactWriteAt,\n): Promise<Record<string, string>> {\n const managerId = crm.reportsToContactId\n if (!managerId) return {}\n if (managerId === at.contactId) {\n return { reportsToContactId: CONTACT_REPORTS_TO_SELF_REFUSAL }\n }\n const read = cachedContactReader(contactsCollection(ctx))\n if (!(await read(managerId))) {\n return { reportsToContactId: 'No such contact in this organization' }\n }\n const facet = facetAt(ctx, at)\n if (\n at.contactId &&\n facet &&\n (await contactReportsToLoops(read, at.contactId, managerId, facet.groupId))\n ) {\n return { reportsToContactId: CONTACT_REPORTS_TO_LOOP_REFUSAL }\n }\n return {}\n}\n\n/**\n * The facet a create writes when the body carried a CRM profile field, under\n * the group of the site the write named — filed as `upsertHostContact` files\n * a capture — plus the top-level `companyIds` the company filter queries.\n *\n * A NESTED object rather than dotted paths, because this is a `create()` and\n * there is no other holder's facet on the row to clobber yet.\n * {@link contactCrmUpdateFields} writes the same fields as paths for the\n * opposite reason.\n */\nfunction contactCrmCreateFields(\n ctx: ApiV1Context,\n crm: ContactCrmInput,\n siteId: string | undefined,\n): Record<string, unknown> {\n const stored = createPayload(crm)\n if (Object.keys(stored).length === 0) return {}\n // The holder's name is the first and last names' when either was given.\n const composed = composeContactName(crm.firstName, crm.lastName)\n if (composed) stored['name'] = composed\n // The person's holder, or the organization when no site was named (AGL-4122).\n const groupId = siteId\n ? consentGroupForHost(ctx.org as Record<string, unknown>, siteId).groupId\n : ORG_CONTACT_HOLDER_ID\n return {\n [CONTACT_FACETS_FIELD]: {\n [groupId]: { sources: { api: true }, interactions: [], ...stored },\n },\n ...(crm.companyId ? { companyIds: [crm.companyId] } : {}),\n }\n}\n\n/**\n * The CRM profile as an `update()` payload: one dotted path per field sent,\n * under the facet of the named site's group — `contactFacetPath`, never a\n * nested object, because an `update` REPLACES a map it is handed whole and\n * the nested form would delete every other holder's profile of the person.\n * `null` becomes a field delete, so a PATCH can clear.\n *\n * `companyIds` is the top-level twin of the facets' `companyId`s, kept so\n * `?companyId=` can be one indexed clause. Which ids it keeps is the CRM's\n * one planner's decision (`planContactCompanyLink`, AGL-2613): the old id\n * leaves only when no OTHER holder still files the person under it, an id\n * some other surface put there is left exactly where it was, and the plan\n * also names the companies whose contacts count the write moves — which the\n * caller settles after the contact is written, because they are other\n * documents.\n */\nfunction contactCrmUpdateFields(\n ctx: ApiV1Context,\n data: FirebaseFirestore.DocumentData,\n crm: ContactCrmInput,\n siteId: string,\n): { update: Record<string, unknown>; link: ContactCompanyLinkPlan | null } {\n const update: Record<string, unknown> = {}\n const group = consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n for (const [field, value] of Object.entries(updatePayload(crm))) {\n update[contactFacetPath(group.groupId, field)] = value\n }\n /*\n * The holder's name follows the first and last names (AGL-3515): either\n * sent recomposes it from what the facet will hold, and both cleared\n * leave it as it stands.\n */\n if (crm.firstName !== undefined || crm.lastName !== undefined) {\n const facet = readContactFacet(data, group.groupId)\n const composed = composeContactName(\n crm.firstName === undefined ? facet.firstName : crm.firstName,\n crm.lastName === undefined ? facet.lastName : crm.lastName,\n )\n if (composed) update[contactFacetPath(group.groupId, 'name')] = composed\n }\n let link: ContactCompanyLinkPlan | null = null\n if (crm.companyId !== undefined) {\n link = planContactCompanyLink(\n readContactCompanyLink(data, group.groupId),\n crm.companyId,\n )\n const mirror = link ? contactCompanyMirrorValue(link) : undefined\n if (mirror !== undefined) update[CONTACT_COMPANY_IDS_FIELD] = mirror\n }\n return { update, link }\n}\n\n/**\n * The holder a contact READ is for, from `?consentSiteId=`: that site's\n * consent group, or `null` for the union view. The parameter the writes take,\n * validated the same way, so a client has one name for \"which site\" on every\n * contact call.\n */\nfunction contactViewGroup(\n ctx: ApiV1Context,\n url: URL,\n): { groupId: string | null } | { response: Response } {\n const siteId = (url.searchParams.get('consentSiteId') ?? '').trim()\n if (!siteId) return { groupId: null }\n if (!orgOwnsHost(ctx, siteId)) {\n return {\n response: ApiErrors.badRequest({\n message: 'Contact filter failed validation',\n code: 'validation_failed',\n fields: { consentSiteId: 'No such site in this organization' },\n headers: ctx.headers,\n }),\n }\n }\n return {\n groupId: consentGroupForHost(ctx.org as Record<string, unknown>, siteId)\n .groupId,\n }\n}\n\nconst contactsCollection = (ctx: ApiV1Context) =>\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts')\n\n/** The org's companies, beside its contacts — where a link's count lands (AGL-2613). */\nconst companiesCollection = (ctx: ApiV1Context) =>\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.companies)\n\n/**\n * `POST /v1/contacts` (AGL-2276) — the call that lets an integration own the\n * customer list.\n *\n * ## The audience band\n *\n * `checkContactQuota` is the gate, and it is the SAME one `upsertHostContact`\n * applies to a form capture (AGL-890). Metered plans always create and bill\n * the overage through the `report-usage` rollup, which counts\n * `orgs/{orgId}/contacts` without caring who wrote them — so an API-created\n * contact meters exactly like a captured one, and there is no unbilled door.\n * Free hard-bands at its included count; free cannot reach `/v1` at all\n * without a staff `features.apiAccess` override, which is precisely the shape\n * AGL-2163 found running unbounded, so the gate is here rather than assumed\n * unreachable.\n *\n * Where capture DROPS a refused contact (silently, onto a\n * `counters/contactsDropped` the console alerts on), this refuses out loud\n * with a `403`: a form must never fail a visitor's signup because of billing,\n * and an API call has an operator on the other end who needs to be told.\n *\n * ## Duplicates\n *\n * A contact is unified on its normalized email, so a second create for an\n * address already present is a `409 conflict` naming the existing id rather\n * than a second row. Silently upserting instead would hide a real integration\n * bug — two upstream systems both claiming to own the record — and would make\n * `POST` and `PATCH` the same call.\n *\n * ## Why the claim is taken ABOVE both refusals\n *\n * `createRecord` and `createDataset` check their quota FIRST and claim after,\n * so that a plan refusal never burns a key. That ordering has a hole this one\n * deliberately does not copy, and `api-v1-contact-writes.spec.ts` is what\n * found it: **a create that exactly fills the band cannot be retried.** The\n * first call succeeds and consumes the last slot; the retry — same key, lost\n * response — re-counts, is now AT the band, and gets a `403` instead of the\n * replay `conventions.md` promises (\"if the original succeeded, the same\n * response comes back\"). The integrator is left unable to tell whether the\n * contact exists, which is the exact confusion the key exists to remove. The\n * duplicate check has the same shape, and worse: the retry's own successful\n * write is what makes the email a duplicate, so EVERY retry of a successful\n * create would answer `409 contact_exists`.\n *\n * Claiming first and RELEASING on each refusal gets both properties at once:\n * a settled key replays before any of this is reached, and a refusal gives\n * the key back so the retry that should finally succeed still can. It is the\n * ordering `deleteRecord` already argues for, and it pays the same price —\n * taking-and-releasing on a genuine refusal. AGL-2278 applies it to the two\n * older creates.\n *\n * `visibleTo` is stamped with `ORG_SCOPE_TOKEN`, as capture does — or, in an\n * org that keeps each site's records apart, the named site's group, which\n * that org must then name. A contact written without one matches no\n * `array-contains-any` and is therefore visible on NO site (AGL-1044) — the\n * API would create data nobody can see, which is worse than refusing.\n */\nasync function createContact(\n request: Request,\n ctx: ApiV1Context,\n): Promise<Response> {\n // Validation stays above the claim, as `createRecord` argues: a\n // deterministic 400 must never take the key at all, so an integrator fixes\n // the payload and retries with the same one.\n const parsed = readContactInput(await readJsonBody(request), {\n partial: false,\n })\n if ('errors' in parsed) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: parsed.errors,\n headers: ctx.headers,\n })\n }\n const {\n email,\n name,\n tags,\n notes,\n marketingConsent,\n consentSiteId,\n consentGroupId,\n crm,\n } = parsed.values\n if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: { consentSiteId: 'No such site in this organization' },\n headers: ctx.headers,\n })\n }\n /*\n * Who may see the new contact: the whole org, unless the org keeps each\n * site's records apart — then the named site's group, and a contact named\n * by no site has nowhere to belong. A deterministic 400, so above the claim.\n */\n const orgDocument = ctx.org as Record<string, unknown>\n const keepsSitesApart = crmDefaultScopeOf(orgDocument) === 'host'\n if (keepsSitesApart && !consentSiteId) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: {\n consentSiteId:\n \"Required — this organization keeps each site's records apart, so name the site this contact belongs to\",\n },\n headers: ctx.headers,\n })\n }\n const visibleTo =\n keepsSitesApart && consentSiteId\n ? crmScopeTokens(orgDocument, consentGroupForHost(orgDocument, consentSiteId))\n : [ORG_SCOPE_TOKEN]\n // Judged against the org's definitions, above the claim like every other\n // deterministic 400: an unknown key is named, never dropped (AGL-2601).\n const custom = parsed.values.custom\n ? readContactCustomInput(\n parsed.values.custom,\n await readOrgContactFieldDefinitions(ctx),\n )\n : null\n if (custom && 'errors' in custom) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: custom.errors,\n headers: ctx.headers,\n })\n }\n // Resolved to the map itself here, above the claim, because the create\n // below is reached through a try block the narrowing above does not\n // survive; an empty map writes no `custom` key at all.\n const customValues = custom && 'values' in custom ? custom.values : {}\n // Still validation, still above the claim: a dangling `companyId` is as\n // deterministic a 400 as a malformed one, and must not burn the key.\n const crmErrors = await contactCrmRefErrors(ctx, crm)\n if (Object.keys(crmErrors).length) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: crmErrors,\n headers: ctx.headers,\n })\n }\n // The canonical name, or the first and last names' for a body with only those.\n const canonicalName = name || composeContactName(crm.firstName, crm.lastName)\n\n const collection = contactsCollection(ctx)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'contacts',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n // Through the org's address index (AGL-2633): an address a merge folded\n // into another record is that record's, and a create on it would mint\n // the duplicate the merge removed.\n // With no address the number is who they are, and with neither nobody is:\n // two blank emails are not the same person (AGL-4122).\n const phone = crm.phone || crm.mobilePhone || ''\n const existing = email\n ? await findContactByEmail(collection, email)\n : phone\n ? ((await collection.where('phone', '==', phone).limit(1).get()).docs[0] ?? null)\n : null\n if (existing) {\n // Released: the conflict clears if that contact is deleted or merged\n // upstream, and the retry that should then succeed must not replay it.\n await claim.release()\n return ApiErrors.conflict({\n message: `A contact with this ${email ? 'email' : 'phone number'} already exists (${existing.id}). Update it instead.`,\n code: 'contact_exists',\n headers: ctx.headers,\n })\n }\n\n // Three aggregate reads, the same ones `upsertHostContact` and the\n // monthly rollup take — the band counts companies and deals beside the\n // contacts (AGL-2611). Unconditional, matching `createDataset` — the\n // alternative is a per-plan shape check like `dataStorageEnforcementShape`,\n // and a `count()` costs one read against a write that costs one anyway.\n const quota = await crmRecordsQuotaForOrg(\n ctx.org as never,\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n collection,\n )\n if (!quota.allowed) {\n await claim.release()\n // `contact_quota` is the code this endpoint has always answered with\n // and integrators match on it; the band behind it widened, the code\n // did not. Companies and deals answer `crm_records_quota`.\n return ApiErrors.planRequired({\n message:\n `CRM records limit reached (${quota.included} records across ` +\n 'contacts, companies and deals). Upgrade the plan to add more.',\n code: 'contact_quota',\n headers: ctx.headers,\n })\n }\n\n const id = createResourceUid()\n await collection.doc(id).create({\n // Blank rather than absent, so a sort by email still lists them.\n email: email ?? '',\n // The number IS who they are with no address: it stays at the top only\n // then, because a profile field otherwise belongs to its holder alone.\n ...(!email && phone ? { phone } : {}),\n // The search keys travel with the name — the console's contact list\n // searches the whole collection, not the page it fetched.\n ...(canonicalName ? nameSearchFields(canonicalName) : {}),\n tags: tags ?? [],\n ...(notes ? { notes } : {}),\n ...(Object.keys(customValues).length ? { custom: customValues } : {}),\n ...(marketingConsent && consentSiteId\n ? {\n // The named site, or its whole declared consent group when the\n // body echoed the group it showed the person (AGL-3320).\n ...marketingConsentFieldsForGroup(\n apiGrantGroup(ctx, consentSiteId, consentGroupId),\n Date.now(),\n ),\n }\n : {}),\n [CAPTURED_BY_HOST_FIELD]: consentSiteId ? [consentSiteId] : [],\n // A refusal carries no site: it stands against every brand in the\n // account — see `readMarketingBasis` for the asymmetry.\n ...(marketingConsent === false ? { marketingConsent: false } : {}),\n // The CRM profile, when the body carried one: under the named site's\n // facet, or the organization's when it named none.\n ...contactCrmCreateFields(ctx, crm, consentSiteId),\n // `sources.api` — a first-class provenance value beside `form`,\n // `member`, `order` and `booking`, so a merchant reading the console\n // can see which people an integration put there.\n sources: { api: true },\n interactions: [],\n // Nothing is scheduled against a new person, which the console's\n // \"No next activity\" filter asks as `null` (AGL-3321).\n nextTaskAtMs: null,\n // AGL-1044/AGL-1037: without this the contact matches no scoped read.\n visibleTo,\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n })\n // What the console's Contacts list searches and filters by (AGL-3321),\n // from the row as it was created, nested facet and all.\n await restampCrmListFieldsAt(collection.doc(id), 'contacts')\n // The address's domain, checked after the response (AGL-3328): a domain\n // that takes no mail makes the new contact read \"Would bounce\".\n if (email) scheduleCapturedEmailCheck({ orgId: ctx.orgId, email })\n // The company the body named has one more contact naming it (AGL-2613);\n // a fresh row's plan is the trivial one, and `crmRefErrors` has already\n // required the company to exist.\n if (crm.companyId) {\n await settleCompanyContactsCounts(\n companiesCollection(ctx),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n crm.companyId,\n ),\n )\n }\n const view = contactView(await collection.doc(id).get())\n // Stored as 200 so a replay is distinguishable from the fresh 201.\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/**\n * `PATCH /v1/contacts/{id}` — the tag/notes edit the console's Contacts page\n * already makes, plus the name and the marketing flag.\n *\n * No `Idempotency-Key`, for `updateRecord`'s reason: the same body twice\n * lands the same state AND returns the same `200`. No quota either — an edit\n * does not grow the audience, and charging a plan refusal for renaming\n * somebody would make a downgraded org unable to correct its own data.\n */\nasync function updateContact(\n request: Request,\n ctx: ApiV1Context,\n contactRef: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const parsed = readContactInput(await readJsonBody(request), { partial: true })\n if ('errors' in parsed) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: parsed.errors,\n headers: ctx.headers,\n })\n }\n const snap = await contactRef.get()\n if (!snap.exists) {\n return ApiErrors.notFound({\n message: 'No such contact',\n headers: ctx.headers,\n })\n }\n\n const {\n name,\n tags,\n notes,\n marketingConsent,\n consentSiteId,\n consentGroupId,\n crm,\n } = parsed.values\n if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: { consentSiteId: 'No such site in this organization' },\n headers: ctx.headers,\n })\n }\n const crmErrors = await contactCrmRefErrors(ctx, crm, {\n contactId: contactRef.id,\n siteId: consentSiteId,\n data: snap.data() ?? {},\n })\n if (Object.keys(crmErrors).length) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: crmErrors,\n headers: ctx.headers,\n })\n }\n const update: Record<string, unknown> = {}\n // A rename must move the search keys with it, or the contact stays findable\n // only by the name they no longer have.\n if (name !== undefined) Object.assign(update, nameSearchFields(name))\n if (tags !== undefined) update.tags = tags\n if (notes !== undefined) update.notes = notes\n if (parsed.values.custom !== undefined) {\n const custom = readContactCustomInput(\n parsed.values.custom,\n await readOrgContactFieldDefinitions(ctx),\n )\n if ('errors' in custom) {\n return ApiErrors.badRequest({\n message: 'Contact failed validation',\n code: 'validation_failed',\n fields: custom.errors,\n headers: ctx.headers,\n })\n }\n /*\n * One dotted path per key, because this is an `update` and an `update`\n * REPLACES a map it is handed whole. `custom` on a PATCH means \"these\n * keys\", the way the console's own save writes only the keys that\n * changed; an integration correcting one value must not have to resend\n * the other nine to keep them (AGL-2601).\n */\n for (const [key, value] of Object.entries(custom.values)) {\n update[`custom.${key}`] = value\n }\n }\n if (marketingConsent === true && consentSiteId) {\n /*\n * A dotted path rather than a nested object, because this is an `update`\n * and an `update` REPLACES a map field it is handed whole. Writing the\n * nested form here would delete every other site's grant — the exact\n * over-application this change exists to end, arriving through the write\n * side instead of the read side.\n */\n // The named site, or its whole declared consent group when the body\n // echoed the group it showed the person (AGL-3320).\n const group = apiGrantGroup(ctx, consentSiteId, consentGroupId)\n for (const hostId of group.hostIds) {\n update[`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}`] = {\n marketingConsent: true,\n // The consent timestamp is the evidence, so it is stamped when\n // consent is GIVEN and left alone when it is withdrawn — an audit\n // needs to know when the person opted in, and clearing it would\n // destroy that record.\n marketingConsentAtMs: Date.now(),\n ...(group.declared\n ? {\n consentGroupId: group.groupId,\n consentGroupName: group.name ?? '',\n }\n : {}),\n }\n }\n } else if (marketingConsent === false) {\n update.marketingConsent = false\n }\n let link: ContactCompanyLinkPlan | null = null\n if (consentSiteId) {\n const crmFields = contactCrmUpdateFields(ctx, snap.data() ?? {}, crm, consentSiteId)\n Object.assign(update, crmFields.update)\n link = crmFields.link\n }\n // An empty body is a no-op answered with the current contact, matching\n // `updateDataset`: a client re-sending an unchanged object should not have\n // to special-case it.\n if (Object.keys(update).length > 0) {\n await contactRef.update({ ...update, updatedAt: Timestamp.now() })\n // What the console's Contacts list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n // The companies the link moved off or onto, counted after the contact\n // is written (AGL-2613); `crmRefErrors` has already required the new\n // company to exist.\n await settleCompanyContactsCounts(companiesCollection(ctx), link)\n }\n // Read back through the site the write named, so what the client sees is\n // the profile it just wrote and not another holder's.\n const groupId = consentSiteId\n ? consentGroupForHost(ctx.org as Record<string, unknown>, consentSiteId)\n .groupId\n : null\n return apiJson(contactView(await contactRef.get(), groupId), {\n headers: ctx.headers,\n })\n}\n\n/**\n * `DELETE /v1/contacts/{id}` — the console's own delete, over the API.\n *\n * Deletes the document whoever else holds it, and keeps every refusal it\n * held — each site's and the unscoped one — in the organization's retained\n * store in the same transaction (AGL-3338), so deleting a person over the\n * API is not a way to add somebody who said no to a list. Then what pointed\n * at the person goes: every other contact's reports-to naming them, in\n * every holder's facet (AGL-3537), and their contact roles on deals\n * (AGL-3521).\n *\n * Takes an `Idempotency-Key` with `deleteRecord`'s exact semantics, and for a\n * sharper reason: an erasure request is the operation most likely to be run\n * from a script on somebody else's deadline, and a retry after a lost\n * response must be able to tell \"already erased\" from \"wrong id\".\n */\nasync function deleteContact(\n request: Request,\n ctx: ApiV1Context,\n contactRef: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'contact-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n // The holders whose facets may name the person as a reports-to (AGL-3537).\n let holders: string[] = []\n const removed = await removeContactKeepingRefusals({\n contactRef,\n decide: (contact) => {\n holders = contactFacetHolders(contact)\n return deleteWholeContact(contact)\n },\n nowMs: Date.now(),\n })\n if (removed.outcome === 'missing') {\n await claim.release()\n return ApiErrors.notFound({\n message: 'No such contact',\n headers: ctx.headers,\n })\n }\n // Nobody reports to a deleted person (AGL-3537)…\n await clearReportsToOf(\n ctx.firestore,\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts'),\n holders,\n contactRef.id,\n )\n // …and they leave every deal's contact roles (AGL-3521).\n await sweepDealContactRoles(\n ctx.firestore,\n ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.deals),\n contactRef.id,\n null,\n '[api-v1] contacts delete',\n )\n const view = { id: contactRef.id, object: 'contact', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/**\n * `GET /v1/contacts` filters (AGL-2460) — the lookup a sync starts with.\n *\n * The list was ordered by document id with no filter of any kind, which left\n * the first question every CRM, mailing tool or migration asks — \"do I\n * already have this person?\" — answerable only by paging the whole audience.\n * That is not a rounding error. Contacts are ORGANIZATION-wide (AGL-237), so\n * the list is the entire audience band; every page is a BILLED request\n * (`recordApiRequest`) against a documented 120/min per-key ceiling. Finding\n * one address in a 50k audience cost ~500 requests and four minutes, and the\n * integration that gave up and called `POST` instead recovered the id by\n * regex over the `409 contact_exists` sentence — parsing a human message for\n * an identifier, which is not a contract this API should have been offering.\n *\n * Both filters REDUCE work rather than adding it: one indexed lookup replaces\n * a full sweep, so this NARROWS the per-request Firestore read amplification\n * AGL-2414 is about rather than widening it. Neither introduces a new metered\n * dimension — a filtered list is the same billed request the unfiltered one\n * always was, and the customer simply needs far fewer of them.\n *\n * ## `email` runs through the writer's own normalizer\n *\n * `normalizeContactEmail` is the SAME function `createContact` stores\n * through. Matching the raw query string instead would make\n * `?email=Avery@Example.com` answer \"no such contact\" while `POST` with that\n * identical address answers `409 contact_exists` naming its id — two\n * endpoints disagreeing about whether a person exists, which an integrator\n * reasonably reads as our data being corrupt rather than as our having two\n * spellings of one address.\n *\n * A value that cannot normalize is a `400` naming the field, not an empty\n * page. Every stored email is normalized and pattern-valid, so nothing can\n * match a malformed one and an empty page would be perfectly TRUE and\n * perfectly useless: it is indistinguishable from \"we don't have them\", and\n * it sends the caller hunting a missing person instead of a typo.\n *\n * ## `email` is a lookup, not a clause\n *\n * The address goes through the org's address index (AGL-2633) — the same\n * lookup every capture door makes — so an address a merge folded into a\n * record as an alternate finds that record, which is the answer `POST`\n * gives when it refuses the same address as a duplicate. One address names\n * one document, so the page is that document or nothing and never carries\n * a cursor.\n *\n * ## Why the COMBINATION filters after the read\n *\n * An `array-contains` on `tags` and the `orderBy(FieldPath.documentId())`\n * every list applies is already a two-clause query; a third would need a\n * composite index we would have to ship and wait on. The address selects at\n * most one document, and a company selects a short page — testing those\n * rows' tags in memory costs nothing and needs no index. The page can\n * therefore come back empty with `has_more` false, which is the short-page\n * case `conventions.md` already tells clients to expect and which\n * `?channel=online` on orders already produces.\n */\nasync function listContacts(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawEmail = url.searchParams.get('email')\n let email: string | null = null\n if (rawEmail !== null && rawEmail.trim() !== '') {\n email = normalizeContactEmail(rawEmail)\n if (!email) {\n return ApiErrors.badRequest({\n message: 'Contact filter failed validation',\n code: 'validation_failed',\n fields: { email: 'Must be a valid email address' },\n headers: ctx.headers,\n })\n }\n }\n // Trimmed and capped exactly as `readContactInput` stores a tag, so a\n // filter cannot ask for a string the write path could never have written.\n const rawTag = url.searchParams.get('tag')\n const tag =\n rawTag === null ? null : rawTag.trim().slice(0, CONTACT_TAG_MAX) || null\n\n /*\n * The CRM filters (AGL-2606). `companyId` can be a clause: it queries the\n * top-level `companyIds` array, the twin every profile write keeps for\n * exactly this. `lifecycleStage` and `ownerUid` cannot — they live on a\n * facet, and a facet field is not queryable without the holder in the\n * path, which the org-wide read does not have — so both are applied to the\n * page, against the SAME profile the view publishes: the named site's, or\n * the union. A filtered page can therefore come back short, as `?email=`\n * with `?tag=` already does. A stage that is not one of the list is a\n * `400`, because `?lifecycleStage=customers` matching nothing is the\n * plausible empty page the conventions refuse to serve.\n */\n const rawStage = (url.searchParams.get('lifecycleStage') ?? '').trim()\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n return ApiErrors.badRequest({\n message: 'Contact filter failed validation',\n code: 'validation_failed',\n fields: {\n lifecycleStage: `Must be one of: ${CONTACT_LIFECYCLE_STAGES.join(', ')}`,\n },\n headers: ctx.headers,\n })\n }\n const lifecycleStage = rawStage || null\n const ownerUid =\n (url.searchParams.get('ownerUid') ?? '').trim().slice(0, CONTACT_NAME_MAX) ||\n null\n const companyId =\n (url.searchParams.get('companyId') ?? '').trim().slice(0, CRM_ID_MAX) || null\n const group = contactViewGroup(ctx, url)\n if ('response' in group) return group.response\n\n // ONE selector, the most selective that was given: the unique email, then\n // the company, then the tag. Every other filter is checked on the page.\n let docs: FirebaseFirestore.DocumentSnapshot[]\n let nextCursor: string | null = null\n let clause: 'email' | 'companyId' | 'tag' | null = null\n if (email) {\n const found = await findContactByEmail(collection, email)\n docs = found ? [found] : []\n clause = 'email'\n } else {\n let query: FirebaseFirestore.Query = collection\n if (companyId) {\n query = query.where('companyIds', 'array-contains', companyId)\n clause = 'companyId'\n } else if (tag) {\n query = query.where('tags', 'array-contains', tag)\n clause = 'tag'\n }\n ;({ docs, nextCursor } = await paginate(query, url))\n }\n const matched = docs.filter((doc) => {\n const data = doc.data() ?? {}\n if (tag && clause !== 'tag') {\n if (!Array.isArray(data.tags) || !data.tags.includes(tag)) return false\n }\n if (companyId && clause !== 'companyId') {\n if (!Array.isArray(data.companyIds) || !data.companyIds.includes(companyId)) {\n return false\n }\n }\n if (lifecycleStage || ownerUid) {\n const profile = contactCrmProfile(data, group.groupId)\n if (lifecycleStage && profile.lifecycleStage !== lifecycleStage) return false\n if (ownerUid && profile.ownerUid !== ownerUid) return false\n }\n return true\n })\n return listResponse(\n matched.map((doc) => contactView(doc, group.groupId)),\n nextCursor,\n ctx.headers,\n )\n}\n\nexport async function handleContacts(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = contactsCollection(ctx)\n const [, contactId] = segments\n\n if (!contactId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'contacts:read')\n if (denied) return denied\n return listContacts(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n return createContact(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const contactRef = collection.doc(contactId)\n\n // `/v1/contacts/{id}/merge` (AGL-2625): the one action on a contact that\n // is not a verb on the document itself.\n if (segments[2] === 'merge' && segments.length === 3) {\n if (request.method !== 'POST') {\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'POST' },\n })\n }\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n const group = contactViewGroup(ctx, url)\n if ('response' in group) return group.response\n return mergeContactRoute(request, ctx, contactRef, (snap) =>\n contactView(snap, group.groupId),\n )\n }\n\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'contacts:read')\n if (denied) return denied\n const group = contactViewGroup(ctx, url)\n if ('response' in group) return group.response\n const snap = await contactRef.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such contact', headers: ctx.headers })\n }\n return apiJson(contactView(snap, group.groupId), { headers: ctx.headers })\n }\n if (request.method === 'PATCH') {\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n return updateContact(request, ctx, contactRef)\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'contacts:write')\n if (denied) return denied\n return deleteContact(request, ctx, contactRef)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, PATCH, DELETE' },\n })\n}\n"],"names":["nameSearchFields","CAPTURED_BY_HOST_FIELD","composeContactName","CONTACT_BIRTHDATE_REFUSAL","CONTACT_COMPANY_IDS_FIELD","CONTACT_EXTRA_PHONE_FIELDS","CONTACT_FACETS_FIELD","ORG_CONTACT_HOLDER_ID","CONTACT_FIELDS_MAX_PER_ORG","CONTACT_LIFECYCLE_STAGES","CONTACT_NAME_PART_MAX","consentGroupForHost","crmDefaultScopeOf","crmScopeTokens","contactFacetPath","createResourceUid","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_MAX","CRM_SALUTATION_PICKLIST","isContactLifecycleStage","judgeCrmPicklistValue","MARKETING_CONSENT_BY_HOST_FIELD","marketingConsentFieldsForGroup","marketingConsentHostIds","normalizeAddress","normalizeContactBirthdate","normalizeContactEmail","normalizeCrmMediaIds","normalizePhone","ORG_SCOPE_TOKEN","planContactCompanyLink","readContactCompanyLink","readContactCustomInput","readContactFacet","soloConsentGroup","ApiErrors","apiJson","contactCompanyMirrorValue","crmRecordsQuotaForOrg","listResponse","restampCrmListFieldsAt","settleCompanyContactsCounts","claimWrite","orgOwnsHost","paginate","readJsonBody","requireScope","serialize","scheduleCapturedEmailCheck","findContactByEmail","deleteWholeContact","removeContactKeepingRefusals","FieldPath","Timestamp","mergeContactRoute","sweepDealContactRoles","cachedContactReader","clearReportsToOf","CONTACT_REPORTS_TO_LOOP_REFUSAL","CONTACT_REPORTS_TO_SELF_REFUSAL","contactReportsToLoops","readCrmPicklist","CRM_ID_MAX","CRM_LABEL_MAX","createPayload","crmRefErrors","memberError","readChoice","readOptionalText","readOrgLeadSourcePicklist","readRefId","updatePayload","resolveCrmPicklistWrite","CONTACT_CRM_FIELDS","contactFacetHolders","data","facets","Array","isArray","Object","keys","sort","contactCrmProfile","groupId","profile","phone","jobTitle","companyId","address","ownerUid","lifecycleStage","leadSource","mediaIds","salutation","firstName","lastName","department","mobilePhone","homePhone","otherPhone","fax","birthdate","assistantName","assistantPhone","reportsToContactId","otherAddress","doNotCall","holder","facet","field","value","held","length","undefined","contactView","doc","id","object","email","name","tags","notes","companyIds","filter","alternateEmails","marketingConsent","consentSites","sources","custom","created","createdAt","updated","updatedAt","readOrgContactFieldDefinitions","ctx","snapshot","firestore","collection","orgId","contactFields","orderBy","documentId","limit","get","docs","map","CONTACT_NAME_MAX","CONTACT_NOTES_MAX","CONTACT_TAGS_MAX","CONTACT_TAG_MAX","CONTACT_WRITABLE","apiGrantGroup","siteId","echoedGroupId","group","org","declared","readContactInput","body","partial","errors","values","crm","allowed","Set","key","has","String","trim","slice","tag","consentSiteId","consentGroupId","normalized","stage","part","text","reportsTo","writesCrm","some","contactCrmRefErrors","at","contactId","facetAt","owner","refs","Promise","all","contactLeadSourceErrors","contactSalutationError","contactReportsToError","current","resolved","ok","error","write","judged","managerId","read","contactsCollection","contactCrmCreateFields","stored","composed","api","interactions","contactCrmUpdateFields","update","entries","link","mirror","contactViewGroup","url","searchParams","response","badRequest","message","code","fields","headers","companiesCollection","companies","createContact","request","parsed","orgDocument","keepsSitesApart","visibleTo","customValues","crmErrors","canonicalName","claimed","replay","claim","existing","where","release","conflict","quota","planRequired","included","create","Date","now","nextTaskAtMs","heldElsewhere","view","record","status","updateContact","contactRef","snap","exists","notFound","assign","hostId","hostIds","marketingConsentAtMs","consentGroupName","crmFields","deleteContact","holders","removed","decide","contact","nowMs","outcome","deals","deleted","listContacts","rawEmail","rawTag","rawStage","join","nextCursor","clause","found","query","matched","includes","handleContacts","segments","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
@@ -25,10 +25,11 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
25
25
  * rather than what somebody later wished had been. It also has to hang off
26
26
  * something — a contact, a company or a deal — because an activity attached
27
27
  * to nothing is a sentence nobody can find from anywhere.
28
- */ import { CRM_COLLECTIONS, CRM_ACTIVITY_DIRECTIONS, CRM_ACTIVITY_KINDS, CRM_ACTIVITY_LOG_FULL_MESSAGE, createResourceUid, crmActivityDirection, crmActivityLogHasRoom } from "@aglyn/aglyn/server";
28
+ */ import { CRM_COLLECTIONS, CRM_ACTIVITY_DIRECTIONS, CRM_ACTIVITY_KINDS, CRM_ACTIVITY_LOG_FULL_MESSAGE, consentGroupForHost, createResourceUid, crmActivityDirection, crmActivityLogHasRoom } from "@aglyn/aglyn/server";
29
29
  import { apiJson, ApiErrors, countCrmActivitiesForRecord } from "@aglyn/tenant-data-admin";
30
30
  import { claimWrite, readJsonBody, requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
31
31
  import { CRM_LABEL_MAX, CRM_TEXT_MAX, CRM_TITLE_MAX, createPayload, crmCollection, crmCreateStamp, crmRefErrors, crmTimes, crmValidationFailed, isoFromMs, listCrm, memberError, parseIsoInstant, readChoice, readCrmSite, readEqualityFilters, readOptionalText, readRefId, refuseUnknownKeys } from "./crm-shared.js";
32
+ import { crmActivityLinkWithCompany } from "../crm-activity-company.js";
32
33
  /** The kinds the API accepts: the CRM's own list, so a kind the console logs is one the API takes. */ const ACTIVITY_KINDS = CRM_ACTIVITY_KINDS;
33
34
  /** The activity object as published. */ function activityView(doc) {
34
35
  var _doc_data, _data_kind, _data_body, _data_byUid, _data_contactId, _data_companyId, _data_dealId, _data_outcome, _data_hostId;
@@ -120,7 +121,9 @@ function readActivityInput(body) {
120
121
  /**
121
122
  * `POST /v1/activities`. `at` defaults to now and `byUid` to `'api'`: an
122
123
  * integration logging a call as it ends is the common case, and the one
123
- * logging it on behalf of a member names them.
124
+ * logging it on behalf of a member names them. An activity that names no
125
+ * company rolls up to its deal's, else its contact's (AGL-4123), as every
126
+ * writer's does, and the response says which.
124
127
  */ async function createActivity(request, ctx) {
125
128
  const body = await readJsonBody(request);
126
129
  const parsed = readActivityInput(body);
@@ -149,6 +152,7 @@ function readActivityInput(body) {
149
152
  "atMs",
150
153
  "byUid"
151
154
  ]);
155
+ const org = ctx.org;
152
156
  // The per-record ceiling (AGL-2611): one aggregate on the record the
153
157
  // activity is filed under. A platform bound rather than a plan one, so
154
158
  // it is a conflict with the record's state, not a plan refusal.
@@ -163,13 +167,19 @@ function readActivityInput(body) {
163
167
  }
164
168
  const id = createResourceUid();
165
169
  const stamp = crmCreateStamp(ctx, site.siteId);
170
+ const { companyId } = await crmActivityLinkWithCompany(ctx.firestore.collection('orgs').doc(ctx.orgId), parsed.values, {
171
+ groupId: site.siteId ? consentGroupForHost(org, site.siteId).groupId : null,
172
+ org
173
+ });
166
174
  await collection.doc(id).create(_extends({
167
175
  kind: kind != null ? kind : 'note',
168
176
  // The instant the record was created, from the same clock as its
169
177
  // `createdAt`, so an activity logged as it happened reads as one moment.
170
178
  atMs: atMs != null ? atMs : stamp.createdAt.toMillis(),
171
179
  byUid: byUid != null ? byUid : 'api'
172
- }, createPayload(rest), stamp));
180
+ }, createPayload(_extends({}, rest, {
181
+ companyId
182
+ })), stamp));
173
183
  const view = activityView(await collection.doc(id).get());
174
184
  await claim.record(200, view);
175
185
  return apiJson(view, {