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

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 (249) hide show
  1. package/package.json +14 -13
  2. package/src/index.d.ts +1 -0
  3. package/src/index.js.map +1 -1
  4. package/src/lib/components/companies-bulk-bar.js +12 -4
  5. package/src/lib/components/companies-bulk-bar.js.map +1 -1
  6. package/src/lib/components/company-detail-page.js +11 -0
  7. package/src/lib/components/company-detail-page.js.map +1 -1
  8. package/src/lib/components/company-edit-drawer.js +12 -2
  9. package/src/lib/components/company-edit-drawer.js.map +1 -1
  10. package/src/lib/components/company-import-drawer.d.ts +1 -1
  11. package/src/lib/components/company-picker.js +10 -3
  12. package/src/lib/components/company-picker.js.map +1 -1
  13. package/src/lib/components/contact-detail-page.d.ts +4 -4
  14. package/src/lib/components/contact-detail-page.js +50 -53
  15. package/src/lib/components/contact-detail-page.js.map +1 -1
  16. package/src/lib/components/contact-list-columns.d.ts +3 -0
  17. package/src/lib/components/contact-list-columns.js +21 -5
  18. package/src/lib/components/contact-list-columns.js.map +1 -1
  19. package/src/lib/components/contact-merge-dialog.js +2 -1
  20. package/src/lib/components/contact-merge-dialog.js.map +1 -1
  21. package/src/lib/components/contact-timeline-card.js +4 -3
  22. package/src/lib/components/contact-timeline-card.js.map +1 -1
  23. package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
  24. package/src/lib/components/contacts-bulk-bar.js +24 -51
  25. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  26. package/src/lib/components/contacts-section.js +9 -3
  27. package/src/lib/components/contacts-section.js.map +1 -1
  28. package/src/lib/components/crm-export-all-button.d.ts +1 -1
  29. package/src/lib/components/crm-export-all-button.js.map +1 -1
  30. package/src/lib/components/crm-send-email-dialog.js +9 -6
  31. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  32. package/src/lib/components/csv-import-drawer.js +2 -2
  33. package/src/lib/components/csv-import-drawer.js.map +1 -1
  34. package/src/lib/components/deal-detail-page.js +18 -2
  35. package/src/lib/components/deal-detail-page.js.map +1 -1
  36. package/src/lib/components/deal-edit-drawer.js +10 -0
  37. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  38. package/src/lib/components/deals-bulk-bar.js +13 -5
  39. package/src/lib/components/deals-bulk-bar.js.map +1 -1
  40. package/src/lib/components/lead-campaigns-card.js +6 -0
  41. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  42. package/src/lib/components/lead-detail-page.js +25 -5
  43. package/src/lib/components/lead-detail-page.js.map +1 -1
  44. package/src/lib/components/lead-properties-card.js +6 -0
  45. package/src/lib/components/lead-properties-card.js.map +1 -1
  46. package/src/lib/components/lead-surfaces-note.js +4 -5
  47. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  48. package/src/lib/components/lead-unqualify-dialog.js +6 -0
  49. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  50. package/src/lib/components/leads-bulk-bar.js +28 -3
  51. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  52. package/src/lib/components/leads-section.js +36 -7
  53. package/src/lib/components/leads-section.js.map +1 -1
  54. package/src/lib/components/org-lead-surfaces-note.js +6 -2
  55. package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
  56. package/src/lib/components/recipes-card.js +9 -4
  57. package/src/lib/components/recipes-card.js.map +1 -1
  58. package/src/lib/components/record-sharing-card.d.ts +57 -0
  59. package/src/lib/components/record-sharing-card.js +244 -0
  60. package/src/lib/components/record-sharing-card.js.map +1 -0
  61. package/src/lib/components/settings-section.js +7 -0
  62. package/src/lib/components/settings-section.js.map +1 -1
  63. package/src/lib/components/share-records-dialog.d.ts +28 -0
  64. package/src/lib/components/share-records-dialog.js +184 -0
  65. package/src/lib/components/share-records-dialog.js.map +1 -0
  66. package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
  67. package/src/lib/components/sharing-rule-drawer.js +374 -0
  68. package/src/lib/components/sharing-rule-drawer.js.map +1 -0
  69. package/src/lib/components/sharing-rules-card.d.ts +24 -0
  70. package/src/lib/components/sharing-rules-card.js +287 -0
  71. package/src/lib/components/sharing-rules-card.js.map +1 -0
  72. package/src/lib/components/use-crm-api.d.ts +1 -1
  73. package/src/lib/components/use-crm-api.js.map +1 -1
  74. package/src/lib/components/use-site-route-context.d.ts +25 -0
  75. package/src/lib/components/use-site-route-context.js +58 -0
  76. package/src/lib/components/use-site-route-context.js.map +1 -0
  77. package/src/lib/constants/api-routes.d.ts +31 -0
  78. package/src/lib/constants/api-routes.js +27 -1
  79. package/src/lib/constants/api-routes.js.map +1 -1
  80. package/src/lib/declarations.console-server.d.ts +37 -0
  81. package/src/lib/declarations.console-server.js +103 -0
  82. package/src/lib/declarations.console-server.js.map +1 -0
  83. package/src/lib/declarations.server.d.ts +6 -0
  84. package/src/lib/declarations.server.js +33 -0
  85. package/src/lib/declarations.server.js.map +1 -1
  86. package/src/lib/hooks/use-contact-remove.d.ts +35 -0
  87. package/src/lib/hooks/use-contact-remove.js +77 -0
  88. package/src/lib/hooks/use-contact-remove.js.map +1 -0
  89. package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
  90. package/src/lib/hooks/use-crm-sharing.js +109 -0
  91. package/src/lib/hooks/use-crm-sharing.js.map +1 -0
  92. package/src/lib/model/companies-csv.d.ts +1 -1
  93. package/src/lib/model/companies-csv.js +2 -2
  94. package/src/lib/model/companies-csv.js.map +1 -1
  95. package/src/lib/model/contact-holder.d.ts +68 -0
  96. package/src/lib/model/contact-holder.js +129 -0
  97. package/src/lib/model/contact-holder.js.map +1 -0
  98. package/src/lib/model/contact-interaction-links.d.ts +46 -0
  99. package/src/lib/model/contact-interaction-links.js +65 -0
  100. package/src/lib/model/contact-interaction-links.js.map +1 -0
  101. package/src/lib/model/contact-merge.d.ts +136 -0
  102. package/src/lib/model/contact-merge.js +412 -0
  103. package/src/lib/model/contact-merge.js.map +1 -0
  104. package/src/lib/model/contact-record.d.ts +7 -1
  105. package/src/lib/model/contact-record.js +6 -5
  106. package/src/lib/model/contact-record.js.map +1 -1
  107. package/src/lib/model/contact-remove.d.ts +48 -0
  108. package/src/lib/model/contact-remove.js +22 -0
  109. package/src/lib/model/contact-remove.js.map +1 -0
  110. package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
  111. package/src/lib/model/contacts-bulk-writes.js +2 -82
  112. package/src/lib/model/contacts-bulk-writes.js.map +1 -1
  113. package/src/lib/model/contacts-csv.d.ts +2 -2
  114. package/src/lib/model/contacts-csv.js +2 -2
  115. package/src/lib/model/contacts-csv.js.map +1 -1
  116. package/src/lib/model/crm-csv.d.ts +239 -0
  117. package/src/lib/model/crm-csv.js +467 -0
  118. package/src/lib/model/crm-csv.js.map +1 -0
  119. package/src/lib/model/crm-digest.d.ts +185 -0
  120. package/src/lib/model/crm-digest.js +272 -0
  121. package/src/lib/model/crm-digest.js.map +1 -0
  122. package/src/lib/model/crm-import.d.ts +7 -1
  123. package/src/lib/model/crm-import.js +2 -1
  124. package/src/lib/model/crm-import.js.map +1 -1
  125. package/src/lib/model/crm-record-routes.d.ts +5 -0
  126. package/src/lib/model/crm-record-routes.js +7 -0
  127. package/src/lib/model/crm-record-routes.js.map +1 -1
  128. package/src/lib/model/crm-routes.d.ts +6 -5
  129. package/src/lib/model/crm-routes.js +7 -7
  130. package/src/lib/model/crm-routes.js.map +1 -1
  131. package/src/lib/model/crm-search-sources.d.ts +15 -0
  132. package/src/lib/model/crm-search-sources.js +162 -0
  133. package/src/lib/model/crm-search-sources.js.map +1 -0
  134. package/src/lib/model/crm-sharing-api.d.ts +32 -0
  135. package/src/lib/model/crm-sharing-api.js +59 -0
  136. package/src/lib/model/crm-sharing-api.js.map +1 -0
  137. package/src/lib/model/crm-sharing.d.ts +333 -0
  138. package/src/lib/model/crm-sharing.js +603 -0
  139. package/src/lib/model/crm-sharing.js.map +1 -0
  140. package/src/lib/model/deals-csv.d.ts +1 -1
  141. package/src/lib/model/deals-csv.js +4 -5
  142. package/src/lib/model/deals-csv.js.map +1 -1
  143. package/src/lib/model/lead-filters.d.ts +1 -1
  144. package/src/lib/model/leads-csv.d.ts +1 -1
  145. package/src/lib/model/leads-csv.js +1 -1
  146. package/src/lib/model/leads-csv.js.map +1 -1
  147. package/src/lib/model/tasks-csv.d.ts +1 -1
  148. package/src/lib/model/tasks-csv.js +1 -1
  149. package/src/lib/model/tasks-csv.js.map +1 -1
  150. package/src/lib/plan-entitlements.d.ts +75 -0
  151. package/src/lib/plan-entitlements.js +56 -0
  152. package/src/lib/plan-entitlements.js.map +1 -0
  153. package/src/lib/plugin.js +3 -0
  154. package/src/lib/plugin.js.map +1 -1
  155. package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
  156. package/src/lib/server/api-v1/contacts-merge.js +120 -0
  157. package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
  158. package/src/lib/server/api-v1/contacts.d.ts +18 -0
  159. package/src/lib/server/api-v1/contacts.js +1076 -0
  160. package/src/lib/server/api-v1/contacts.js.map +1 -0
  161. package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
  162. package/src/lib/server/api-v1/crm-activities.js +269 -0
  163. package/src/lib/server/api-v1/crm-activities.js.map +1 -0
  164. package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
  165. package/src/lib/server/api-v1/crm-companies.js +391 -0
  166. package/src/lib/server/api-v1/crm-companies.js.map +1 -0
  167. package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
  168. package/src/lib/server/api-v1/crm-deals.js +631 -0
  169. package/src/lib/server/api-v1/crm-deals.js.map +1 -0
  170. package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
  171. package/src/lib/server/api-v1/crm-email-templates.js +336 -0
  172. package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
  173. package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
  174. package/src/lib/server/api-v1/crm-leads.js +832 -0
  175. package/src/lib/server/api-v1/crm-leads.js.map +1 -0
  176. package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
  177. package/src/lib/server/api-v1/crm-pipelines.js +146 -0
  178. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
  179. package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
  180. package/src/lib/server/api-v1/crm-shared.js +436 -0
  181. package/src/lib/server/api-v1/crm-shared.js.map +1 -0
  182. package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
  183. package/src/lib/server/api-v1/crm-tasks.js +398 -0
  184. package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
  185. package/src/lib/server/api-v1/openapi.d.ts +29 -0
  186. package/src/lib/server/api-v1/openapi.js +985 -0
  187. package/src/lib/server/api-v1/openapi.js.map +1 -0
  188. package/src/lib/server/api-v1/usage.d.ts +26 -0
  189. package/src/lib/server/api-v1/usage.js +73 -0
  190. package/src/lib/server/api-v1/usage.js.map +1 -0
  191. package/src/lib/server/consent-group-participant.js +1 -1
  192. package/src/lib/server/consent-group-participant.js.map +1 -1
  193. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  194. package/src/lib/server/contact-lifecycle-floor.js +91 -0
  195. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  196. package/src/lib/server/contact-remove.d.ts +75 -0
  197. package/src/lib/server/contact-remove.js +244 -0
  198. package/src/lib/server/contact-remove.js.map +1 -0
  199. package/src/lib/server/contact-update.js +2 -1
  200. package/src/lib/server/contact-update.js.map +1 -1
  201. package/src/lib/server/contacts-import.d.ts +47 -11
  202. package/src/lib/server/contacts-import.js +171 -71
  203. package/src/lib/server/contacts-import.js.map +1 -1
  204. package/src/lib/server/contacts-merge.d.ts +1 -1
  205. package/src/lib/server/contacts-merge.js +3 -2
  206. package/src/lib/server/contacts-merge.js.map +1 -1
  207. package/src/lib/server/convert-host-lead.d.ts +189 -0
  208. package/src/lib/server/convert-host-lead.js +518 -0
  209. package/src/lib/server/convert-host-lead.js.map +1 -0
  210. package/src/lib/server/crm-sharing.d.ts +108 -0
  211. package/src/lib/server/crm-sharing.js +790 -0
  212. package/src/lib/server/crm-sharing.js.map +1 -0
  213. package/src/lib/server/daily-digest-route.d.ts +107 -0
  214. package/src/lib/server/daily-digest-route.js +588 -0
  215. package/src/lib/server/daily-digest-route.js.map +1 -0
  216. package/src/lib/server/export-route.d.ts +60 -0
  217. package/src/lib/server/export-route.js +447 -0
  218. package/src/lib/server/export-route.js.map +1 -0
  219. package/src/lib/server/inbound-route.d.ts +74 -0
  220. package/src/lib/server/inbound-route.js +257 -0
  221. package/src/lib/server/inbound-route.js.map +1 -0
  222. package/src/lib/server/lead-convert.d.ts +1 -1
  223. package/src/lib/server/lead-convert.js +2 -2
  224. package/src/lib/server/lead-convert.js.map +1 -1
  225. package/src/lib/server/merge-contacts.d.ts +55 -0
  226. package/src/lib/server/merge-contacts.js +274 -0
  227. package/src/lib/server/merge-contacts.js.map +1 -0
  228. package/src/lib/server/pipeline-record-index.d.ts +36 -0
  229. package/src/lib/server/pipeline-record-index.js +87 -0
  230. package/src/lib/server/pipeline-record-index.js.map +1 -0
  231. package/src/lib/server/record-facts.js +2 -1
  232. package/src/lib/server/record-facts.js.map +1 -1
  233. package/src/lib/server/task-reminders-route.d.ts +82 -0
  234. package/src/lib/server/task-reminders-route.js +497 -0
  235. package/src/lib/server/task-reminders-route.js.map +1 -0
  236. package/src/lib/server-deal-stage.js +2 -1
  237. package/src/lib/server-deal-stage.js.map +1 -1
  238. package/src/lib/server.d.ts +5 -2
  239. package/src/lib/server.js +47 -2
  240. package/src/lib/server.js.map +1 -1
  241. package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
  242. package/src/lib/testing/stand-in-site-record-routes.js +55 -0
  243. package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
  244. package/src/lib/usage-axes.d.ts +35 -0
  245. package/src/lib/usage-axes.js +68 -0
  246. package/src/lib/usage-axes.js.map +1 -0
  247. package/src/lib/components/use-emails-hub-path.d.ts +0 -24
  248. package/src/lib/components/use-emails-hub-path.js +0 -59
  249. package/src/lib/components/use-emails-hub-path.js.map +0 -1
@@ -0,0 +1,1076 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
18
+ import { CAPTURED_BY_HOST_FIELD, CONTACT_COMPANY_IDS_FIELD, CONTACT_FACETS_FIELD, CONTACT_FIELDS_MAX_PER_ORG, CONTACT_LIFECYCLE_STAGES, consentGroupForHost, contactFacetPath, createResourceUid, CRM_COLLECTIONS, CRM_MEDIA_IDS_MAX, isContactLifecycleStage, MARKETING_CONSENT_BY_HOST_FIELD, marketingConsentFieldsForGroup, marketingConsentHostIds, normalizeAddress, normalizeContactEmail, normalizeCrmMediaIds, normalizePhone, ORG_SCOPE_TOKEN, planContactCompanyLink, readContactCompanyLink, readContactCustomInput, readContactFacet, soloConsentGroup } from "@aglyn/aglyn/server";
19
+ import { ApiErrors, apiJson, contactCompanyMirrorValue, crmRecordsQuotaForOrg, listResponse, restampCrmListFieldsAt, settleCompanyContactsCounts } from "@aglyn/tenant-data-admin";
20
+ import { claimWrite, orgOwnsHost, paginate, readJsonBody, requireScope, serialize } from "@aglyn/tenant-data-admin/server/api-v1-kit";
21
+ // The leaf, so a spec that stands a partial barrel in still reaches it.
22
+ import { scheduleCapturedEmailCheck } from "@aglyn/tenant-data-admin/server/capture-email-check";
23
+ // The leaf, not the barrel: the console's API specs substitute the barrel
24
+ // wholesale, and the lookup must reach the real index logic under them.
25
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
26
+ // The leaf for the same reason: the refusals a deleted contact held must be
27
+ // kept by the real store under a spec's barrel (AGL-3338).
28
+ import { deleteWholeContact, removeContactKeepingRefusals } from "@aglyn/tenant-data-admin/server/retained-refusals";
29
+ import { FieldPath, Timestamp } from "firebase-admin/firestore";
30
+ import { mergeContactRoute } from "./contacts-merge.js";
31
+ import { CRM_ID_MAX, CRM_LABEL_MAX, createPayload, crmRefErrors, memberError, readChoice, readOptionalText, readRefId, updatePayload } from "./crm-shared.js";
32
+ /**
33
+ * `/v1/contacts` (AGL-618, AGL-2276, AGL-2606): the organization's people —
34
+ * list, read, create, update, delete, and `POST /v1/contacts/{id}/merge` —
35
+ * served by the CRM, which keeps them. The pipeline in front of it (the key,
36
+ * the plan's API access, the quota, the rate limit, the envelope) is the
37
+ * console router's; the CRM suite's plan refusal is declared with the
38
+ * registration (`declarations.console-server.ts`).
39
+ */ // ── Contacts (read) ─────────────────────────────────────────────────────────
40
+ /**
41
+ * The contact object as published.
42
+ *
43
+ * `notes` and `marketingConsent` are here because `PATCH` writes them
44
+ * (AGL-2276). A projection that omits a field the same resource accepts is
45
+ * the shape that has shipped broken before: the client writes, reads back,
46
+ * sees nothing, and cannot tell a dropped write from a narrow view. Every
47
+ * writable field appears here, and `contact-writes.spec.ts` asserts that as a
48
+ * property of the pair rather than field by field.
49
+ *
50
+ * `email`, `sources` and `interactions` are read-only and stay that way —
51
+ * `email` is the dedupe key the whole CRM unifies on, and the other two are
52
+ * provenance. `interactions` is not published at all; the console's timeline
53
+ * is not part of the API contract.
54
+ */ /**
55
+ * The CRM profile a contact carries (AGL-2606): the six fields a sales team
56
+ * keeps on a person, read from the FACETS because that is where the console
57
+ * keeps them — per holder, under `facets.{groupId}` (`ContactFacet`), so two
58
+ * unrelated businesses sharing one row never read each other's knowledge of
59
+ * the person.
60
+ *
61
+ * An API key is an organization credential, and every facet on the row is
62
+ * the organization's own; so with no site named the profile is the UNION of
63
+ * the holders — each field from the first holder, in stable id order, that
64
+ * has set it. That is the right read for the caller a key represents (an
65
+ * integration acting for the account) and the only one that does not force
66
+ * every read to name a site. Naming one (`?consentSiteId=`) reads that
67
+ * site's group alone, which is what a per-brand sync wants and what a PATCH
68
+ * that just wrote through that site reads back.
69
+ *
70
+ * `null` for every unset field rather than an absent key, so a client can
71
+ * tell "no phone" from "this API does not publish phones".
72
+ */ const CONTACT_CRM_FIELDS = [
73
+ 'phone',
74
+ 'jobTitle',
75
+ 'companyId',
76
+ 'address',
77
+ 'ownerUid',
78
+ 'lifecycleStage',
79
+ // Files are a facet field like the rest (AGL-2662): an agency running two
80
+ // client brands has one contact document between them, and a contract one
81
+ // client filed is not the other client's to read.
82
+ 'mediaIds'
83
+ ];
84
+ /** The groups holding a facet on this row, in stable order. */ function contactFacetHolders(data) {
85
+ const facets = data[CONTACT_FACETS_FIELD];
86
+ return facets && typeof facets === 'object' && !Array.isArray(facets) ? Object.keys(facets).sort() : [];
87
+ }
88
+ function contactCrmProfile(data, groupId) {
89
+ const profile = {
90
+ phone: null,
91
+ jobTitle: null,
92
+ companyId: null,
93
+ address: null,
94
+ ownerUid: null,
95
+ lifecycleStage: null,
96
+ // An empty ARRAY rather than null, so a client can index it without a
97
+ // guard — the same shape `custom` publishes for the same reason.
98
+ mediaIds: []
99
+ };
100
+ for (const holder of groupId ? [
101
+ groupId
102
+ ] : contactFacetHolders(data)){
103
+ const facet = readContactFacet(data, holder);
104
+ for (const field of CONTACT_CRM_FIELDS){
105
+ const value = facet[field];
106
+ if (field === 'mediaIds') {
107
+ // The union of every holder's attachments when no site was named,
108
+ // and one holder's when one was — the same rule the scalar fields
109
+ // follow, spelled apart because a list merges rather than wins.
110
+ const held = profile[field];
111
+ if (!held.length) profile[field] = normalizeCrmMediaIds(value);
112
+ continue;
113
+ }
114
+ if (profile[field] === null && value !== undefined && value !== null) {
115
+ profile[field] = value;
116
+ }
117
+ }
118
+ }
119
+ return profile;
120
+ }
121
+ /**
122
+ * @param groupId - the holder whose CRM profile to publish, or `null` for
123
+ * the union — see {@link contactCrmProfile}.
124
+ */ function contactView(doc, groupId = null) {
125
+ var _doc_data, _data_email, _data_name, _data_tags, _data_notes, _serialize, _serialize1;
126
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
127
+ return _extends({
128
+ id: doc.id,
129
+ object: 'contact',
130
+ email: (_data_email = data.email) != null ? _data_email : null,
131
+ name: (_data_name = data.name) != null ? _data_name : null,
132
+ tags: (_data_tags = data.tags) != null ? _data_tags : [],
133
+ notes: (_data_notes = data.notes) != null ? _data_notes : null
134
+ }, contactCrmProfile(data, groupId), {
135
+ // Every company any holder has filed the person under — the top-level
136
+ // twin of the facets' `companyId`, and what `?companyId=` queries.
137
+ companyIds: Array.isArray(data.companyIds) ? data.companyIds.filter((id)=>typeof id === 'string') : [],
138
+ // The other addresses a merge folded into this record (AGL-2625) —
139
+ // identity like `email`, and read-only for the same reason.
140
+ alternateEmails: Array.isArray(data.alternateEmails) ? data.alternateEmails.filter((email)=>typeof email === 'string') : [],
141
+ /*
142
+ * TRUE means "some site may mail this person", and `consentSites` says
143
+ * which. A single boolean is what the org-wide model published, and it is
144
+ * exactly the claim that turned out to be wrong: an agency's key read
145
+ * `true` and could not tell which of its brands the person had agreed to
146
+ * hear from. Both are published so a client can act on either.
147
+ */ marketingConsent: data.marketingConsent === false ? false : marketingConsentHostIds(data).length > 0,
148
+ consentSites: marketingConsentHostIds(data),
149
+ sources: data.sources ? Object.keys(data.sources) : [],
150
+ /*
151
+ * The org's custom field values, keyed by field key (AGL-2601). The same
152
+ * top-level map `tags` and `notes` beside it are read from — this resource
153
+ * is the ORGANIZATION's view, and its writes land there. An empty object
154
+ * rather than `null`, so a client can index it without a guard.
155
+ */ custom: data.custom && typeof data.custom === 'object' && !Array.isArray(data.custom) ? data.custom : {},
156
+ created: (_serialize = serialize(data.createdAt)) != null ? _serialize : null,
157
+ updated: (_serialize1 = serialize(data.updatedAt)) != null ? _serialize1 : null
158
+ });
159
+ }
160
+ /**
161
+ * The org's custom field definitions, retired ones included, for the API's
162
+ * `custom` validation (AGL-2601).
163
+ *
164
+ * One bounded read of a small collection, paid only when a body carries
165
+ * `custom` — the reader is called from the two writers, never from the view.
166
+ * Retired definitions come back too because `readContactCustomInput` refuses
167
+ * a write under one BY NAME, which is a better answer than "no such field"
168
+ * for a key the integration wrote last month.
169
+ */ async function readOrgContactFieldDefinitions(ctx) {
170
+ const snapshot = await ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.contactFields).orderBy(FieldPath.documentId()).limit(CONTACT_FIELDS_MAX_PER_ORG).get();
171
+ return snapshot.docs.map((doc)=>doc.data());
172
+ }
173
+ const CONTACT_NAME_MAX = 120;
174
+ const CONTACT_NOTES_MAX = 2000;
175
+ const CONTACT_TAGS_MAX = 50;
176
+ const CONTACT_TAG_MAX = 60;
177
+ /** Fields a client may send. Anything else is named, never silently dropped. */ const CONTACT_WRITABLE = [
178
+ 'name',
179
+ 'tags',
180
+ 'notes',
181
+ 'marketingConsent',
182
+ 'consentSiteId',
183
+ 'consentGroupId',
184
+ 'custom'
185
+ ];
186
+ /**
187
+ * The sites an API opt-in is recorded for (AGL-3320): the named site's whole
188
+ * consent group only when the body ECHOED that group's id, and the site
189
+ * alone otherwise.
190
+ *
191
+ * An integration's own signup form is a capture surface this platform never
192
+ * sees, so it cannot prove which sentence it showed the way a Form block
193
+ * does. Echoing the group's id is the integration saying it showed the
194
+ * group's name; an opt-in that says nothing about the group, or names one
195
+ * the site is no longer in, is the one site's — narrow, which is the safe
196
+ * way to be wrong, and visible in the response's `consentSites`.
197
+ */ function apiGrantGroup(ctx, siteId, echoedGroupId) {
198
+ const group = consentGroupForHost(ctx.org, siteId);
199
+ return group.declared && echoedGroupId === group.groupId ? group : soloConsentGroup(siteId);
200
+ }
201
+ function readContactInput(body, { partial }) {
202
+ const errors = {};
203
+ const values = {
204
+ crm: {}
205
+ };
206
+ const allowed = new Set([
207
+ ...CONTACT_WRITABLE,
208
+ ...CONTACT_CRM_FIELDS,
209
+ ...partial ? [] : [
210
+ 'email'
211
+ ]
212
+ ]);
213
+ for (const key of Object.keys(body)){
214
+ if (allowed.has(key)) continue;
215
+ errors[key] = key === 'email' ? 'Not writable — a contact is identified by its email' : key === 'sources' || key === 'interactions' ? 'Not writable — set by the capture point that recorded it' : 'Not writable on a contact';
216
+ }
217
+ if (!partial) {
218
+ // The SAME normalizer every capture point uses. Re-implementing the
219
+ // check here would let the API accept an address `upsertHostContact`
220
+ // would reject, and the two would then disagree about who is a duplicate.
221
+ const email = normalizeContactEmail(body.email);
222
+ if (!email) errors.email = 'A valid email address is required';
223
+ else values.email = email;
224
+ }
225
+ if (body.name !== undefined) {
226
+ var _body_name;
227
+ const name = String((_body_name = body.name) != null ? _body_name : '').trim().slice(0, CONTACT_NAME_MAX);
228
+ if (name) values.name = name;
229
+ else errors.name = 'Must not be empty';
230
+ }
231
+ if (body.tags !== undefined) {
232
+ if (!Array.isArray(body.tags)) {
233
+ errors.tags = 'Must be an array of strings';
234
+ } else {
235
+ // An EMPTY array is legal and means "clear the tags" — the console's
236
+ // own tag editor can empty the field, and an API that could only ever
237
+ // add tags would leave an integration unable to undo its own mistake.
238
+ values.tags = body.tags.map((tag)=>String(tag).trim().slice(0, CONTACT_TAG_MAX)).filter((tag)=>tag.length > 0).slice(0, CONTACT_TAGS_MAX);
239
+ }
240
+ }
241
+ if (body.notes !== undefined) {
242
+ var _body_notes;
243
+ values.notes = String((_body_notes = body.notes) != null ? _body_notes : '').slice(0, CONTACT_NOTES_MAX);
244
+ }
245
+ if (body.consentSiteId !== undefined) {
246
+ var _body_consentSiteId;
247
+ const siteId = String((_body_consentSiteId = body.consentSiteId) != null ? _body_consentSiteId : '').trim();
248
+ if (!siteId) errors.consentSiteId = 'Must name a site';
249
+ else values.consentSiteId = siteId;
250
+ }
251
+ if (body.consentGroupId !== undefined) {
252
+ const groupId = typeof body.consentGroupId === 'string' ? body.consentGroupId.trim() : '';
253
+ if (!groupId) errors.consentGroupId = 'Must name a consent group';
254
+ else values.consentGroupId = groupId;
255
+ }
256
+ if (body.marketingConsent !== undefined) {
257
+ if (typeof body.marketingConsent !== 'boolean') {
258
+ errors.marketingConsent = 'Must be true or false';
259
+ } else {
260
+ values.marketingConsent = body.marketingConsent;
261
+ }
262
+ }
263
+ if (body.custom !== undefined) {
264
+ if (!body.custom || typeof body.custom !== 'object' || Array.isArray(body.custom)) {
265
+ errors.custom = 'Must be an object of field values keyed by field key';
266
+ } else {
267
+ values.custom = body.custom;
268
+ }
269
+ }
270
+ /*
271
+ * The CRM profile (AGL-2606). Each value runs through the normalizer the
272
+ * console writes with — `normalizePhone`, `normalizeAddress`, the lifecycle
273
+ * list — so the API cannot store a phone the console would refuse, or an
274
+ * address of empty strings the console would read as "has an address". A
275
+ * value that does not survive names the field rather than storing the raw
276
+ * string: a half-normalized phone is the unusable number `normalizePhone`
277
+ * exists to end. The readers are `crm-shared`'s, so a contact and a company
278
+ * bound their text the same way. `ownerUid` and `companyId` are checked for
279
+ * existence by the caller, after this synchronous pass, so a body already
280
+ * refused never spends the reads.
281
+ */ const crm = {};
282
+ const phone = readOptionalText(body, 'phone', CRM_LABEL_MAX, errors);
283
+ if (phone === null) {
284
+ crm.phone = null;
285
+ } else if (phone !== undefined) {
286
+ const normalized = normalizePhone(phone);
287
+ if (normalized) crm.phone = normalized;
288
+ else errors.phone = 'Must be a phone number with a country code, like +15125550123';
289
+ }
290
+ const jobTitle = readOptionalText(body, 'jobTitle', CONTACT_NAME_MAX, errors);
291
+ if (jobTitle !== undefined) crm.jobTitle = jobTitle;
292
+ const companyId = readRefId(body, 'companyId', errors);
293
+ if (companyId !== undefined) crm.companyId = companyId;
294
+ if (body.address !== undefined) {
295
+ if (body.address === null) {
296
+ crm.address = null;
297
+ } else if (typeof body.address !== 'object' || Array.isArray(body.address)) {
298
+ errors.address = 'Must be an address object';
299
+ } else {
300
+ // A blank address normalizes to `null`, which clears.
301
+ crm.address = normalizeAddress(body.address);
302
+ }
303
+ }
304
+ const ownerUid = readOptionalText(body, 'ownerUid', CONTACT_NAME_MAX, errors);
305
+ if (ownerUid !== undefined) crm.ownerUid = ownerUid;
306
+ if (body.mediaIds !== undefined) {
307
+ if (!Array.isArray(body.mediaIds)) {
308
+ errors.mediaIds = 'Must be an array of media ids';
309
+ } else if (body.mediaIds.length > CRM_MEDIA_IDS_MAX) {
310
+ errors.mediaIds = `At most ${CRM_MEDIA_IDS_MAX} files may be attached`;
311
+ } else {
312
+ // The SAME normalizer the console card writes through. An empty array
313
+ // is legal and clears this holder's attachments.
314
+ crm.mediaIds = normalizeCrmMediaIds(body.mediaIds);
315
+ }
316
+ }
317
+ if (body.lifecycleStage === null) {
318
+ crm.lifecycleStage = null;
319
+ } else {
320
+ const stage = readChoice(body, 'lifecycleStage', CONTACT_LIFECYCLE_STAGES, errors);
321
+ if (stage) crm.lifecycleStage = stage;
322
+ }
323
+ values.crm = crm;
324
+ // Whether the body MEANT to write a profile — judged by the keys it sent,
325
+ // not by which of them parsed, so a body whose every profile value was
326
+ // refused is told about those values and not also that its site was
327
+ // unwelcome.
328
+ const writesCrm = CONTACT_CRM_FIELDS.some((field)=>body[field] !== undefined);
329
+ /*
330
+ * AN OPT-IN MUST NAME THE SITE IT WAS GIVEN TO; A REFUSAL MUST NOT.
331
+ *
332
+ * An API key belongs to an ORGANIZATION, and an organization is not a
333
+ * controller — an agency's key reaches every client brand it runs. So an
334
+ * integration asserting that somebody opted in has to say which brand they
335
+ * opted in to, exactly as a form does by being served from one site. There
336
+ * is no safe default: picking the org's only site works until the org has
337
+ * two, and picking none is the org-wide grant this field exists to stop.
338
+ *
339
+ * A refusal is the mirror image and is refused a site on purpose. It
340
+ * applies to every brand in the account, which is what `readMarketingBasis`
341
+ * does with an unscoped `false`, and accepting a site alongside it would
342
+ * imply a per-brand opt-out this endpoint does not write.
343
+ */ if (values.marketingConsent === true && !values.consentSiteId) {
344
+ errors.consentSiteId = 'Required with marketingConsent: true — name the site the person opted in to';
345
+ }
346
+ /*
347
+ * A CRM PROFILE FIELD NAMES THE SITE TOO (AGL-2606), for the facet's
348
+ * reason: the profile is one holder's knowledge of the person and lives
349
+ * under that holder's group, so a write has to say whose. The same
350
+ * parameter, because it is the same question — which of the organization's
351
+ * sites is this write made on behalf of — and an integrator should not
352
+ * learn two names for it. Beside a refusal the site is accepted, since the
353
+ * refusal still applies to every site and the site now names the facet.
354
+ */ if (writesCrm && !values.consentSiteId) {
355
+ errors.consentSiteId = 'Required with a CRM profile field — name the site whose profile of this person to write';
356
+ }
357
+ if (values.marketingConsent === false && values.consentSiteId && !writesCrm) {
358
+ errors.consentSiteId = 'Not accepted with marketingConsent: false — a refusal applies to every site';
359
+ }
360
+ if (values.consentSiteId && values.marketingConsent === undefined && !writesCrm) {
361
+ errors.consentSiteId = 'Only accepted alongside marketingConsent or a CRM profile field';
362
+ }
363
+ /*
364
+ * THE GROUP IS ECHOED ONLY BESIDE AN OPT-IN (AGL-3320). It says which
365
+ * disclosure the person saw, so it widens a grant to the site's consent
366
+ * group and means nothing anywhere else — least of all beside a refusal,
367
+ * which already stands against every site.
368
+ */ if (values.consentGroupId && values.marketingConsent !== true) {
369
+ errors.consentGroupId = 'Only accepted with marketingConsent: true — name the consent group the person was shown';
370
+ }
371
+ return Object.keys(errors).length ? {
372
+ errors
373
+ } : {
374
+ values
375
+ };
376
+ }
377
+ /**
378
+ * The two references a contact's CRM profile can carry, checked for
379
+ * existence — the SAME checks a deal or a task makes on its own `ownerUid`
380
+ * and `companyId`, so a contact cannot point at a company `/v1/deals` would
381
+ * refuse. Run after the synchronous grammar, so a body already refused never
382
+ * spends the reads.
383
+ */ async function contactCrmRefErrors(ctx, crm) {
384
+ var _crm_companyId;
385
+ const [owner, refs] = await Promise.all([
386
+ memberError(ctx, 'ownerUid', crm.ownerUid),
387
+ crmRefErrors(ctx, {
388
+ companyId: (_crm_companyId = crm.companyId) != null ? _crm_companyId : undefined
389
+ })
390
+ ]);
391
+ return _extends({}, owner, refs);
392
+ }
393
+ /**
394
+ * The facet a create writes when the body carried a CRM profile field, under
395
+ * the group of the site the write named — filed as `upsertHostContact` files
396
+ * a capture — plus the top-level `companyIds` the company filter queries.
397
+ *
398
+ * A NESTED object rather than dotted paths, because this is a `create()` and
399
+ * there is no other holder's facet on the row to clobber yet.
400
+ * {@link contactCrmUpdateFields} writes the same fields as paths for the
401
+ * opposite reason.
402
+ */ function contactCrmCreateFields(ctx, crm, siteId) {
403
+ const stored = createPayload(crm);
404
+ if (Object.keys(stored).length === 0) return {};
405
+ const group = consentGroupForHost(ctx.org, siteId);
406
+ return _extends({
407
+ [CONTACT_FACETS_FIELD]: {
408
+ [group.groupId]: _extends({
409
+ sources: {
410
+ api: true
411
+ },
412
+ interactions: []
413
+ }, stored)
414
+ }
415
+ }, crm.companyId ? {
416
+ companyIds: [
417
+ crm.companyId
418
+ ]
419
+ } : {});
420
+ }
421
+ /**
422
+ * The CRM profile as an `update()` payload: one dotted path per field sent,
423
+ * under the facet of the named site's group — `contactFacetPath`, never a
424
+ * nested object, because an `update` REPLACES a map it is handed whole and
425
+ * the nested form would delete every other holder's profile of the person.
426
+ * `null` becomes a field delete, so a PATCH can clear.
427
+ *
428
+ * `companyIds` is the top-level twin of the facets' `companyId`s, kept so
429
+ * `?companyId=` can be one indexed clause. Which ids it keeps is the CRM's
430
+ * one planner's decision (`planContactCompanyLink`, AGL-2613): the old id
431
+ * leaves only when no OTHER holder still files the person under it, an id
432
+ * some other surface put there is left exactly where it was, and the plan
433
+ * also names the companies whose contacts count the write moves — which the
434
+ * caller settles after the contact is written, because they are other
435
+ * documents.
436
+ */ function contactCrmUpdateFields(ctx, data, crm, siteId) {
437
+ const update = {};
438
+ const group = consentGroupForHost(ctx.org, siteId);
439
+ for (const [field, value] of Object.entries(updatePayload(crm))){
440
+ update[contactFacetPath(group.groupId, field)] = value;
441
+ }
442
+ let link = null;
443
+ if (crm.companyId !== undefined) {
444
+ link = planContactCompanyLink(readContactCompanyLink(data, group.groupId), crm.companyId);
445
+ const mirror = link ? contactCompanyMirrorValue(link) : undefined;
446
+ if (mirror !== undefined) update[CONTACT_COMPANY_IDS_FIELD] = mirror;
447
+ }
448
+ return {
449
+ update,
450
+ link
451
+ };
452
+ }
453
+ /**
454
+ * The holder a contact READ is for, from `?consentSiteId=`: that site's
455
+ * consent group, or `null` for the union view. The parameter the writes take,
456
+ * validated the same way, so a client has one name for "which site" on every
457
+ * contact call.
458
+ */ function contactViewGroup(ctx, url) {
459
+ var _url_searchParams_get;
460
+ const siteId = ((_url_searchParams_get = url.searchParams.get('consentSiteId')) != null ? _url_searchParams_get : '').trim();
461
+ if (!siteId) return {
462
+ groupId: null
463
+ };
464
+ if (!orgOwnsHost(ctx, siteId)) {
465
+ return {
466
+ response: ApiErrors.badRequest({
467
+ message: 'Contact filter failed validation',
468
+ code: 'validation_failed',
469
+ fields: {
470
+ consentSiteId: 'No such site in this organization'
471
+ },
472
+ headers: ctx.headers
473
+ })
474
+ };
475
+ }
476
+ return {
477
+ groupId: consentGroupForHost(ctx.org, siteId).groupId
478
+ };
479
+ }
480
+ const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts');
481
+ /** The org's companies, beside its contacts — where a link's count lands (AGL-2613). */ const companiesCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.companies);
482
+ /**
483
+ * `POST /v1/contacts` (AGL-2276) — the call that lets an integration own the
484
+ * customer list.
485
+ *
486
+ * ## The audience band
487
+ *
488
+ * `checkContactQuota` is the gate, and it is the SAME one `upsertHostContact`
489
+ * applies to a form capture (AGL-890). Metered plans always create and bill
490
+ * the overage through the `report-usage` rollup, which counts
491
+ * `orgs/{orgId}/contacts` without caring who wrote them — so an API-created
492
+ * contact meters exactly like a captured one, and there is no unbilled door.
493
+ * Free hard-bands at its included count; free cannot reach `/v1` at all
494
+ * without a staff `features.apiAccess` override, which is precisely the shape
495
+ * AGL-2163 found running unbounded, so the gate is here rather than assumed
496
+ * unreachable.
497
+ *
498
+ * Where capture DROPS a refused contact (silently, onto a
499
+ * `counters/contactsDropped` the console alerts on), this refuses out loud
500
+ * with a `403`: a form must never fail a visitor's signup because of billing,
501
+ * and an API call has an operator on the other end who needs to be told.
502
+ *
503
+ * ## Duplicates
504
+ *
505
+ * A contact is unified on its normalized email, so a second create for an
506
+ * address already present is a `409 conflict` naming the existing id rather
507
+ * than a second row. Silently upserting instead would hide a real integration
508
+ * bug — two upstream systems both claiming to own the record — and would make
509
+ * `POST` and `PATCH` the same call.
510
+ *
511
+ * ## Why the claim is taken ABOVE both refusals
512
+ *
513
+ * `createRecord` and `createDataset` check their quota FIRST and claim after,
514
+ * so that a plan refusal never burns a key. That ordering has a hole this one
515
+ * deliberately does not copy, and `api-v1-contact-writes.spec.ts` is what
516
+ * found it: **a create that exactly fills the band cannot be retried.** The
517
+ * first call succeeds and consumes the last slot; the retry — same key, lost
518
+ * response — re-counts, is now AT the band, and gets a `403` instead of the
519
+ * replay `conventions.md` promises ("if the original succeeded, the same
520
+ * response comes back"). The integrator is left unable to tell whether the
521
+ * contact exists, which is the exact confusion the key exists to remove. The
522
+ * duplicate check has the same shape, and worse: the retry's own successful
523
+ * write is what makes the email a duplicate, so EVERY retry of a successful
524
+ * create would answer `409 contact_exists`.
525
+ *
526
+ * Claiming first and RELEASING on each refusal gets both properties at once:
527
+ * a settled key replays before any of this is reached, and a refusal gives
528
+ * the key back so the retry that should finally succeed still can. It is the
529
+ * ordering `deleteRecord` already argues for, and it pays the same price —
530
+ * taking-and-releasing on a genuine refusal. AGL-2278 applies it to the two
531
+ * older creates.
532
+ *
533
+ * `visibleTo` is stamped with `ORG_SCOPE_TOKEN`, as capture does. A contact
534
+ * written without it matches no `array-contains-any` and is therefore visible
535
+ * on NO site (AGL-1044) — the API would create data nobody can see, which is
536
+ * worse than refusing.
537
+ */ async function createContact(request, ctx) {
538
+ // Validation stays above the claim, as `createRecord` argues: a
539
+ // deterministic 400 must never take the key at all, so an integrator fixes
540
+ // the payload and retries with the same one.
541
+ const parsed = readContactInput(await readJsonBody(request), {
542
+ partial: false
543
+ });
544
+ if ('errors' in parsed) {
545
+ return ApiErrors.badRequest({
546
+ message: 'Contact failed validation',
547
+ code: 'validation_failed',
548
+ fields: parsed.errors,
549
+ headers: ctx.headers
550
+ });
551
+ }
552
+ const { email, name, tags, notes, marketingConsent, consentSiteId, consentGroupId, crm } = parsed.values;
553
+ if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {
554
+ return ApiErrors.badRequest({
555
+ message: 'Contact failed validation',
556
+ code: 'validation_failed',
557
+ fields: {
558
+ consentSiteId: 'No such site in this organization'
559
+ },
560
+ headers: ctx.headers
561
+ });
562
+ }
563
+ // Judged against the org's definitions, above the claim like every other
564
+ // deterministic 400: an unknown key is named, never dropped (AGL-2601).
565
+ const custom = parsed.values.custom ? readContactCustomInput(parsed.values.custom, await readOrgContactFieldDefinitions(ctx)) : null;
566
+ if (custom && 'errors' in custom) {
567
+ return ApiErrors.badRequest({
568
+ message: 'Contact failed validation',
569
+ code: 'validation_failed',
570
+ fields: custom.errors,
571
+ headers: ctx.headers
572
+ });
573
+ }
574
+ // Resolved to the map itself here, above the claim, because the create
575
+ // below is reached through a try block the narrowing above does not
576
+ // survive; an empty map writes no `custom` key at all.
577
+ const customValues = custom && 'values' in custom ? custom.values : {};
578
+ // Still validation, still above the claim: a dangling `companyId` is as
579
+ // deterministic a 400 as a malformed one, and must not burn the key.
580
+ const crmErrors = await contactCrmRefErrors(ctx, crm);
581
+ if (Object.keys(crmErrors).length) {
582
+ return ApiErrors.badRequest({
583
+ message: 'Contact failed validation',
584
+ code: 'validation_failed',
585
+ fields: crmErrors,
586
+ headers: ctx.headers
587
+ });
588
+ }
589
+ const collection = contactsCollection(ctx);
590
+ const claimed = await claimWrite(ctx, '*', request.headers.get('Idempotency-Key'), 'contacts');
591
+ if ('replay' in claimed) return claimed.replay;
592
+ const { claim } = claimed;
593
+ try {
594
+ // Through the org's address index (AGL-2633): an address a merge folded
595
+ // into another record is that record's, and a create on it would mint
596
+ // the duplicate the merge removed.
597
+ const existing = await findContactByEmail(collection, email);
598
+ if (existing) {
599
+ // Released: the conflict clears if that contact is deleted or merged
600
+ // upstream, and the retry that should then succeed must not replay it.
601
+ await claim.release();
602
+ return ApiErrors.conflict({
603
+ message: `A contact with this email already exists (${existing.id}). Update it instead.`,
604
+ code: 'contact_exists',
605
+ headers: ctx.headers
606
+ });
607
+ }
608
+ // Three aggregate reads, the same ones `upsertHostContact` and the
609
+ // monthly rollup take — the band counts companies and deals beside the
610
+ // contacts (AGL-2611). Unconditional, matching `createDataset` — the
611
+ // alternative is a per-plan shape check like `dataStorageEnforcementShape`,
612
+ // and a `count()` costs one read against a write that costs one anyway.
613
+ const quota = await crmRecordsQuotaForOrg(ctx.org, ctx.firestore.collection('orgs').doc(ctx.orgId), collection);
614
+ if (!quota.allowed) {
615
+ await claim.release();
616
+ // `contact_quota` is the code this endpoint has always answered with
617
+ // and integrators match on it; the band behind it widened, the code
618
+ // did not. Companies and deals answer `crm_records_quota`.
619
+ return ApiErrors.planRequired({
620
+ message: `CRM records limit reached (${quota.included} records across ` + 'contacts, companies and deals). Upgrade the plan to add more.',
621
+ code: 'contact_quota',
622
+ headers: ctx.headers
623
+ });
624
+ }
625
+ const id = createResourceUid();
626
+ await collection.doc(id).create(_extends({
627
+ email
628
+ }, name ? nameSearchFields(name) : {}, {
629
+ tags: tags != null ? tags : []
630
+ }, notes ? {
631
+ notes
632
+ } : {}, Object.keys(customValues).length ? {
633
+ custom: customValues
634
+ } : {}, marketingConsent && consentSiteId ? _extends({}, marketingConsentFieldsForGroup(apiGrantGroup(ctx, consentSiteId, consentGroupId), Date.now())) : {}, {
635
+ [CAPTURED_BY_HOST_FIELD]: consentSiteId ? [
636
+ consentSiteId
637
+ ] : []
638
+ }, marketingConsent === false ? {
639
+ marketingConsent: false
640
+ } : {}, consentSiteId ? contactCrmCreateFields(ctx, crm, consentSiteId) : {}, {
641
+ // `sources.api` — a first-class provenance value beside `form`,
642
+ // `member`, `order` and `booking`, so a merchant reading the console
643
+ // can see which people an integration put there.
644
+ sources: {
645
+ api: true
646
+ },
647
+ interactions: [],
648
+ // Nothing is scheduled against a new person, which the console's
649
+ // "No next activity" filter asks as `null` (AGL-3321).
650
+ nextTaskAtMs: null,
651
+ // AGL-1044/AGL-1037: without this the contact matches no scoped read.
652
+ visibleTo: [
653
+ ORG_SCOPE_TOKEN
654
+ ],
655
+ createdAt: Timestamp.now(),
656
+ updatedAt: Timestamp.now()
657
+ }));
658
+ // What the console's Contacts list searches and filters by (AGL-3321),
659
+ // from the row as it was created, nested facet and all.
660
+ await restampCrmListFieldsAt(collection.doc(id), 'contacts');
661
+ // The address's domain, checked after the response (AGL-3328): a domain
662
+ // that takes no mail makes the new contact read "Would bounce".
663
+ scheduleCapturedEmailCheck({
664
+ orgId: ctx.orgId,
665
+ email
666
+ });
667
+ // The company the body named has one more contact naming it (AGL-2613);
668
+ // a fresh row's plan is the trivial one, and `crmRefErrors` has already
669
+ // required the company to exist.
670
+ if (consentSiteId && crm.companyId) {
671
+ await settleCompanyContactsCounts(companiesCollection(ctx), planContactCompanyLink({
672
+ companyId: null,
673
+ companyIds: [],
674
+ heldElsewhere: []
675
+ }, crm.companyId));
676
+ }
677
+ const view = contactView(await collection.doc(id).get());
678
+ // Stored as 200 so a replay is distinguishable from the fresh 201.
679
+ await claim.record(200, view);
680
+ return apiJson(view, {
681
+ status: 201,
682
+ headers: ctx.headers
683
+ });
684
+ } catch (error) {
685
+ await claim.release();
686
+ throw error;
687
+ }
688
+ }
689
+ /**
690
+ * `PATCH /v1/contacts/{id}` — the tag/notes edit the console's Contacts page
691
+ * already makes, plus the name and the marketing flag.
692
+ *
693
+ * No `Idempotency-Key`, for `updateRecord`'s reason: the same body twice
694
+ * lands the same state AND returns the same `200`. No quota either — an edit
695
+ * does not grow the audience, and charging a plan refusal for renaming
696
+ * somebody would make a downgraded org unable to correct its own data.
697
+ */ async function updateContact(request, ctx, contactRef) {
698
+ const parsed = readContactInput(await readJsonBody(request), {
699
+ partial: true
700
+ });
701
+ if ('errors' in parsed) {
702
+ return ApiErrors.badRequest({
703
+ message: 'Contact failed validation',
704
+ code: 'validation_failed',
705
+ fields: parsed.errors,
706
+ headers: ctx.headers
707
+ });
708
+ }
709
+ const snap = await contactRef.get();
710
+ if (!snap.exists) {
711
+ return ApiErrors.notFound({
712
+ message: 'No such contact',
713
+ headers: ctx.headers
714
+ });
715
+ }
716
+ const { name, tags, notes, marketingConsent, consentSiteId, consentGroupId, crm } = parsed.values;
717
+ if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {
718
+ return ApiErrors.badRequest({
719
+ message: 'Contact failed validation',
720
+ code: 'validation_failed',
721
+ fields: {
722
+ consentSiteId: 'No such site in this organization'
723
+ },
724
+ headers: ctx.headers
725
+ });
726
+ }
727
+ const crmErrors = await contactCrmRefErrors(ctx, crm);
728
+ if (Object.keys(crmErrors).length) {
729
+ return ApiErrors.badRequest({
730
+ message: 'Contact failed validation',
731
+ code: 'validation_failed',
732
+ fields: crmErrors,
733
+ headers: ctx.headers
734
+ });
735
+ }
736
+ const update = {};
737
+ // A rename must move the search keys with it, or the contact stays findable
738
+ // only by the name they no longer have.
739
+ if (name !== undefined) Object.assign(update, nameSearchFields(name));
740
+ if (tags !== undefined) update.tags = tags;
741
+ if (notes !== undefined) update.notes = notes;
742
+ if (parsed.values.custom !== undefined) {
743
+ const custom = readContactCustomInput(parsed.values.custom, await readOrgContactFieldDefinitions(ctx));
744
+ if ('errors' in custom) {
745
+ return ApiErrors.badRequest({
746
+ message: 'Contact failed validation',
747
+ code: 'validation_failed',
748
+ fields: custom.errors,
749
+ headers: ctx.headers
750
+ });
751
+ }
752
+ /*
753
+ * One dotted path per key, because this is an `update` and an `update`
754
+ * REPLACES a map it is handed whole. `custom` on a PATCH means "these
755
+ * keys", the way the console's own save writes only the keys that
756
+ * changed; an integration correcting one value must not have to resend
757
+ * the other nine to keep them (AGL-2601).
758
+ */ for (const [key, value] of Object.entries(custom.values)){
759
+ update[`custom.${key}`] = value;
760
+ }
761
+ }
762
+ if (marketingConsent === true && consentSiteId) {
763
+ /*
764
+ * A dotted path rather than a nested object, because this is an `update`
765
+ * and an `update` REPLACES a map field it is handed whole. Writing the
766
+ * nested form here would delete every other site's grant — the exact
767
+ * over-application this change exists to end, arriving through the write
768
+ * side instead of the read side.
769
+ */ // The named site, or its whole declared consent group when the body
770
+ // echoed the group it showed the person (AGL-3320).
771
+ const group = apiGrantGroup(ctx, consentSiteId, consentGroupId);
772
+ for (const hostId of group.hostIds){
773
+ var _group_name;
774
+ update[`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}`] = _extends({
775
+ marketingConsent: true,
776
+ // The consent timestamp is the evidence, so it is stamped when
777
+ // consent is GIVEN and left alone when it is withdrawn — an audit
778
+ // needs to know when the person opted in, and clearing it would
779
+ // destroy that record.
780
+ marketingConsentAtMs: Date.now()
781
+ }, group.declared ? {
782
+ consentGroupId: group.groupId,
783
+ consentGroupName: (_group_name = group.name) != null ? _group_name : ''
784
+ } : {});
785
+ }
786
+ } else if (marketingConsent === false) {
787
+ update.marketingConsent = false;
788
+ }
789
+ let link = null;
790
+ if (consentSiteId) {
791
+ var _snap_data;
792
+ const crmFields = contactCrmUpdateFields(ctx, (_snap_data = snap.data()) != null ? _snap_data : {}, crm, consentSiteId);
793
+ Object.assign(update, crmFields.update);
794
+ link = crmFields.link;
795
+ }
796
+ // An empty body is a no-op answered with the current contact, matching
797
+ // `updateDataset`: a client re-sending an unchanged object should not have
798
+ // to special-case it.
799
+ if (Object.keys(update).length > 0) {
800
+ await contactRef.update(_extends({}, update, {
801
+ updatedAt: Timestamp.now()
802
+ }));
803
+ // What the console's Contacts list searches and filters by (AGL-3321).
804
+ await restampCrmListFieldsAt(contactRef, 'contacts');
805
+ // The companies the link moved off or onto, counted after the contact
806
+ // is written (AGL-2613); `crmRefErrors` has already required the new
807
+ // company to exist.
808
+ await settleCompanyContactsCounts(companiesCollection(ctx), link);
809
+ }
810
+ // Read back through the site the write named, so what the client sees is
811
+ // the profile it just wrote and not another holder's.
812
+ const groupId = consentSiteId ? consentGroupForHost(ctx.org, consentSiteId).groupId : null;
813
+ return apiJson(contactView(await contactRef.get(), groupId), {
814
+ headers: ctx.headers
815
+ });
816
+ }
817
+ /**
818
+ * `DELETE /v1/contacts/{id}` — the console's own delete, over the API.
819
+ *
820
+ * Deletes the document whoever else holds it, and keeps every refusal it
821
+ * held — each site's and the unscoped one — in the organization's retained
822
+ * store in the same transaction (AGL-3338), so deleting a person over the
823
+ * API is not a way to add somebody who said no to a list.
824
+ *
825
+ * Takes an `Idempotency-Key` with `deleteRecord`'s exact semantics, and for a
826
+ * sharper reason: an erasure request is the operation most likely to be run
827
+ * from a script on somebody else's deadline, and a retry after a lost
828
+ * response must be able to tell "already erased" from "wrong id".
829
+ */ async function deleteContact(request, ctx, contactRef) {
830
+ const claimed = await claimWrite(ctx, '*', request.headers.get('Idempotency-Key'), 'contact-deletes');
831
+ if ('replay' in claimed) return claimed.replay;
832
+ const { claim } = claimed;
833
+ try {
834
+ const removed = await removeContactKeepingRefusals({
835
+ contactRef,
836
+ decide: deleteWholeContact,
837
+ nowMs: Date.now()
838
+ });
839
+ if (removed.outcome === 'missing') {
840
+ await claim.release();
841
+ return ApiErrors.notFound({
842
+ message: 'No such contact',
843
+ headers: ctx.headers
844
+ });
845
+ }
846
+ const view = {
847
+ id: contactRef.id,
848
+ object: 'contact',
849
+ deleted: true
850
+ };
851
+ await claim.record(200, view);
852
+ return apiJson(view, {
853
+ headers: ctx.headers
854
+ });
855
+ } catch (error) {
856
+ await claim.release();
857
+ throw error;
858
+ }
859
+ }
860
+ /**
861
+ * `GET /v1/contacts` filters (AGL-2460) — the lookup a sync starts with.
862
+ *
863
+ * The list was ordered by document id with no filter of any kind, which left
864
+ * the first question every CRM, mailing tool or migration asks — "do I
865
+ * already have this person?" — answerable only by paging the whole audience.
866
+ * That is not a rounding error. Contacts are ORGANIZATION-wide (AGL-237), so
867
+ * the list is the entire audience band; every page is a BILLED request
868
+ * (`recordApiRequest`) against a documented 120/min per-key ceiling. Finding
869
+ * one address in a 50k audience cost ~500 requests and four minutes, and the
870
+ * integration that gave up and called `POST` instead recovered the id by
871
+ * regex over the `409 contact_exists` sentence — parsing a human message for
872
+ * an identifier, which is not a contract this API should have been offering.
873
+ *
874
+ * Both filters REDUCE work rather than adding it: one indexed lookup replaces
875
+ * a full sweep, so this NARROWS the per-request Firestore read amplification
876
+ * AGL-2414 is about rather than widening it. Neither introduces a new metered
877
+ * dimension — a filtered list is the same billed request the unfiltered one
878
+ * always was, and the customer simply needs far fewer of them.
879
+ *
880
+ * ## `email` runs through the writer's own normalizer
881
+ *
882
+ * `normalizeContactEmail` is the SAME function `createContact` stores
883
+ * through. Matching the raw query string instead would make
884
+ * `?email=Avery@Example.com` answer "no such contact" while `POST` with that
885
+ * identical address answers `409 contact_exists` naming its id — two
886
+ * endpoints disagreeing about whether a person exists, which an integrator
887
+ * reasonably reads as our data being corrupt rather than as our having two
888
+ * spellings of one address.
889
+ *
890
+ * A value that cannot normalize is a `400` naming the field, not an empty
891
+ * page. Every stored email is normalized and pattern-valid, so nothing can
892
+ * match a malformed one and an empty page would be perfectly TRUE and
893
+ * perfectly useless: it is indistinguishable from "we don't have them", and
894
+ * it sends the caller hunting a missing person instead of a typo.
895
+ *
896
+ * ## `email` is a lookup, not a clause
897
+ *
898
+ * The address goes through the org's address index (AGL-2633) — the same
899
+ * lookup every capture door makes — so an address a merge folded into a
900
+ * record as an alternate finds that record, which is the answer `POST`
901
+ * gives when it refuses the same address as a duplicate. One address names
902
+ * one document, so the page is that document or nothing and never carries
903
+ * a cursor.
904
+ *
905
+ * ## Why the COMBINATION filters after the read
906
+ *
907
+ * An `array-contains` on `tags` and the `orderBy(FieldPath.documentId())`
908
+ * every list applies is already a two-clause query; a third would need a
909
+ * composite index we would have to ship and wait on. The address selects at
910
+ * most one document, and a company selects a short page — testing those
911
+ * rows' tags in memory costs nothing and needs no index. The page can
912
+ * therefore come back empty with `has_more` false, which is the short-page
913
+ * case `conventions.md` already tells clients to expect and which
914
+ * `?channel=online` on orders already produces.
915
+ */ async function listContacts(ctx, collection, url) {
916
+ var _url_searchParams_get, _url_searchParams_get1, _url_searchParams_get2;
917
+ const rawEmail = url.searchParams.get('email');
918
+ let email = null;
919
+ if (rawEmail !== null && rawEmail.trim() !== '') {
920
+ email = normalizeContactEmail(rawEmail);
921
+ if (!email) {
922
+ return ApiErrors.badRequest({
923
+ message: 'Contact filter failed validation',
924
+ code: 'validation_failed',
925
+ fields: {
926
+ email: 'Must be a valid email address'
927
+ },
928
+ headers: ctx.headers
929
+ });
930
+ }
931
+ }
932
+ // Trimmed and capped exactly as `readContactInput` stores a tag, so a
933
+ // filter cannot ask for a string the write path could never have written.
934
+ const rawTag = url.searchParams.get('tag');
935
+ const tag = rawTag === null ? null : rawTag.trim().slice(0, CONTACT_TAG_MAX) || null;
936
+ /*
937
+ * The CRM filters (AGL-2606). `companyId` can be a clause: it queries the
938
+ * top-level `companyIds` array, the twin every profile write keeps for
939
+ * exactly this. `lifecycleStage` and `ownerUid` cannot — they live on a
940
+ * facet, and a facet field is not queryable without the holder in the
941
+ * path, which the org-wide read does not have — so both are applied to the
942
+ * page, against the SAME profile the view publishes: the named site's, or
943
+ * the union. A filtered page can therefore come back short, as `?email=`
944
+ * with `?tag=` already does. A stage that is not one of the list is a
945
+ * `400`, because `?lifecycleStage=customers` matching nothing is the
946
+ * plausible empty page the conventions refuse to serve.
947
+ */ const rawStage = ((_url_searchParams_get = url.searchParams.get('lifecycleStage')) != null ? _url_searchParams_get : '').trim();
948
+ if (rawStage && !isContactLifecycleStage(rawStage)) {
949
+ return ApiErrors.badRequest({
950
+ message: 'Contact filter failed validation',
951
+ code: 'validation_failed',
952
+ fields: {
953
+ lifecycleStage: `Must be one of: ${CONTACT_LIFECYCLE_STAGES.join(', ')}`
954
+ },
955
+ headers: ctx.headers
956
+ });
957
+ }
958
+ const lifecycleStage = rawStage || null;
959
+ const ownerUid = ((_url_searchParams_get1 = url.searchParams.get('ownerUid')) != null ? _url_searchParams_get1 : '').trim().slice(0, CONTACT_NAME_MAX) || null;
960
+ const companyId = ((_url_searchParams_get2 = url.searchParams.get('companyId')) != null ? _url_searchParams_get2 : '').trim().slice(0, CRM_ID_MAX) || null;
961
+ const group = contactViewGroup(ctx, url);
962
+ if ('response' in group) return group.response;
963
+ // ONE selector, the most selective that was given: the unique email, then
964
+ // the company, then the tag. Every other filter is checked on the page.
965
+ let docs;
966
+ let nextCursor = null;
967
+ let clause = null;
968
+ if (email) {
969
+ const found = await findContactByEmail(collection, email);
970
+ docs = found ? [
971
+ found
972
+ ] : [];
973
+ clause = 'email';
974
+ } else {
975
+ let query = collection;
976
+ if (companyId) {
977
+ query = query.where('companyIds', 'array-contains', companyId);
978
+ clause = 'companyId';
979
+ } else if (tag) {
980
+ query = query.where('tags', 'array-contains', tag);
981
+ clause = 'tag';
982
+ }
983
+ ;
984
+ ({ docs, nextCursor } = await paginate(query, url));
985
+ }
986
+ const matched = docs.filter((doc)=>{
987
+ var _doc_data;
988
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
989
+ if (tag && clause !== 'tag') {
990
+ if (!Array.isArray(data.tags) || !data.tags.includes(tag)) return false;
991
+ }
992
+ if (companyId && clause !== 'companyId') {
993
+ if (!Array.isArray(data.companyIds) || !data.companyIds.includes(companyId)) {
994
+ return false;
995
+ }
996
+ }
997
+ if (lifecycleStage || ownerUid) {
998
+ const profile = contactCrmProfile(data, group.groupId);
999
+ if (lifecycleStage && profile.lifecycleStage !== lifecycleStage) return false;
1000
+ if (ownerUid && profile.ownerUid !== ownerUid) return false;
1001
+ }
1002
+ return true;
1003
+ });
1004
+ return listResponse(matched.map((doc)=>contactView(doc, group.groupId)), nextCursor, ctx.headers);
1005
+ }
1006
+ export async function handleContacts(request, ctx, segments, url) {
1007
+ const collection = contactsCollection(ctx);
1008
+ const [, contactId] = segments;
1009
+ if (!contactId) {
1010
+ if (request.method === 'GET') {
1011
+ const denied = requireScope(ctx, 'contacts:read');
1012
+ if (denied) return denied;
1013
+ return listContacts(ctx, collection, url);
1014
+ }
1015
+ if (request.method === 'POST') {
1016
+ const denied = requireScope(ctx, 'contacts:write');
1017
+ if (denied) return denied;
1018
+ return createContact(request, ctx);
1019
+ }
1020
+ return ApiErrors.methodNotAllowed({
1021
+ headers: _extends({}, ctx.headers, {
1022
+ Allow: 'GET, POST'
1023
+ })
1024
+ });
1025
+ }
1026
+ const contactRef = collection.doc(contactId);
1027
+ // `/v1/contacts/{id}/merge` (AGL-2625): the one action on a contact that
1028
+ // is not a verb on the document itself.
1029
+ if (segments[2] === 'merge' && segments.length === 3) {
1030
+ if (request.method !== 'POST') {
1031
+ return ApiErrors.methodNotAllowed({
1032
+ headers: _extends({}, ctx.headers, {
1033
+ Allow: 'POST'
1034
+ })
1035
+ });
1036
+ }
1037
+ const denied = requireScope(ctx, 'contacts:write');
1038
+ if (denied) return denied;
1039
+ const group = contactViewGroup(ctx, url);
1040
+ if ('response' in group) return group.response;
1041
+ return mergeContactRoute(request, ctx, contactRef, (snap)=>contactView(snap, group.groupId));
1042
+ }
1043
+ if (request.method === 'GET') {
1044
+ const denied = requireScope(ctx, 'contacts:read');
1045
+ if (denied) return denied;
1046
+ const group = contactViewGroup(ctx, url);
1047
+ if ('response' in group) return group.response;
1048
+ const snap = await contactRef.get();
1049
+ if (!snap.exists) {
1050
+ return ApiErrors.notFound({
1051
+ message: 'No such contact',
1052
+ headers: ctx.headers
1053
+ });
1054
+ }
1055
+ return apiJson(contactView(snap, group.groupId), {
1056
+ headers: ctx.headers
1057
+ });
1058
+ }
1059
+ if (request.method === 'PATCH') {
1060
+ const denied = requireScope(ctx, 'contacts:write');
1061
+ if (denied) return denied;
1062
+ return updateContact(request, ctx, contactRef);
1063
+ }
1064
+ if (request.method === 'DELETE') {
1065
+ const denied = requireScope(ctx, 'contacts:write');
1066
+ if (denied) return denied;
1067
+ return deleteContact(request, ctx, contactRef);
1068
+ }
1069
+ return ApiErrors.methodNotAllowed({
1070
+ headers: _extends({}, ctx.headers, {
1071
+ Allow: 'GET, PATCH, DELETE'
1072
+ })
1073
+ });
1074
+ }
1075
+
1076
+ //# sourceMappingURL=contacts.js.map