@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,832 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ /**
4
+ * @license
5
+ * Copyright 2026 Aglyn LLC
6
+ *
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */ /**
19
+ * `/v1/leads` (AGL-2627) — the work queue: every person a site captured,
20
+ * with the working state a sales team keeps on them and the door that turns
21
+ * one into a contact.
22
+ *
23
+ * ## A lead belongs to a site, so every request names one
24
+ *
25
+ * The five CRM collections sit under the org and a key reads them whole
26
+ * (`crm-shared.ts`). A lead does not: it lives at
27
+ * `hosts/{siteId}/leads/{id}`, private to the site that captured it, with no
28
+ * `visibleTo` to filter on and an id that is only unique within its site —
29
+ * the person key, so one address is one row per site. There is no org-wide
30
+ * list to read, and `/v1/leads/{id}` alone names a row in every site the org
31
+ * has. So `siteId` is required on every endpoint here, as a query parameter
32
+ * (a `PATCH` or `POST` may carry it in the body instead), and a site the
33
+ * organization does not own is refused the way `consentSiteId` is on a
34
+ * create. That is also what keeps this resource mountable at the org level:
35
+ * the site is a parameter, never an assumption.
36
+ *
37
+ * ## Newest first, and the status filter runs on the page
38
+ *
39
+ * A polling integration asks for what arrived since it last looked, so the
40
+ * list is ordered by `lastSeen` descending — the one field the capture door
41
+ * stamps on every lead, at every capture — with the id as tiebreak, and the
42
+ * cursor carries both. `?status=` is applied to the page rather than sent to
43
+ * Firestore, for a reason a `where` cannot answer: a lead nobody has touched
44
+ * carries no `status` field at all and reads as `new` (`crmLeadStatus`), so
45
+ * `where('status', '==', 'new')` would miss exactly the leads a work queue
46
+ * exists to show. The page can come back short, which the conventions page
47
+ * already documents; no composite index is needed and none is shipped.
48
+ *
49
+ * ## A lead is a record of its own (AGL-3231)
50
+ *
51
+ * `POST /v1/leads` creates one — Salesforce's lead, sourced outside the
52
+ * site and entered before anyone has qualified it: the address, a name,
53
+ * the company as text, a title, a phone, a website, an address, tags and a
54
+ * lead source, with the working state beside them. It goes through the one
55
+ * lead door (`addHostLead`), so a second create for one address updates the
56
+ * lead the site holds and answers `200` rather than minting a second row;
57
+ * it writes no contact and no company, which are the conversion's to make;
58
+ * and it records no marketing basis, because an integration is not a box
59
+ * the person ticked. The same profile fields are writable on a `PATCH`,
60
+ * `null` clearing one, and every one of them is published on the lead.
61
+ *
62
+ * ## What a PATCH may say about a status
63
+ *
64
+ * The same three moves the console offers: `new`, `working`, and
65
+ * `unqualified` with a reason. `qualified` is the converted state — a lead
66
+ * reaches it through `POST /v1/leads/{id}/convert` and nowhere else, because
67
+ * the status is a claim that a contact exists and the conversion is what
68
+ * makes it true. A converted lead's status is fixed for the same reason.
69
+ * Reopening an unqualified lead drops its reason, as the console does: a
70
+ * lead being worked again is not "unqualified because …".
71
+ */ import { checkVisitorRecordCeiling, CRM_LEAD_STATUSES, crmLeadStatus, crmMemberOption, findOrgMember, LEADS_MAX_PER_HOST, normalizeCompanyDomain, normalizeContactEmail, crmPicklistDefaultLabel, judgeCrmLeadSource, normalizeCrmLeadProfile, personKey, readMarketingBasis, soloConsentGroup } from "@aglyn/aglyn/server";
72
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
73
+ import { addHostLead, apiJson, ApiErrors, decodeCursor, encodeCursor, listOrgMembers, listResponse, parseLimit, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
74
+ import { convertHostLead } from "../convert-host-lead.js";
75
+ import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
76
+ import { claimWrite, orgOwnsHost, readJsonBody, requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
77
+ import { CRM_ID_MAX, CRM_LABEL_MAX, CRM_TEXT_MAX, CRM_TITLE_MAX, crmCustomUpdate, crmCustomView, crmTimes, crmValidationFailed, isoFromMs, memberError, readCrmCustomBody, readOptionalText, readOrgLeadSourcePicklist, refuseUnknownKeys, updatePayload } from "./crm-shared.js";
78
+ /**
79
+ * The statuses a PATCH may set — every status but the converted one.
80
+ *
81
+ * A literal, checked against the model's union, rather than a filter over
82
+ * `CRM_LEAD_STATUSES`: nothing in this module may dereference the model at
83
+ * load time, for the reason `crm-shared.ts` gives — the `/v1` suites mock
84
+ * `@aglyn/aglyn/server` as a closed world, and a module-level read of a
85
+ * constant they never asked for throws at import in every one of them.
86
+ */ const LEAD_PATCH_STATUSES = [
87
+ 'new',
88
+ 'working',
89
+ 'unqualified'
90
+ ];
91
+ // ── The view ────────────────────────────────────────────────────────────────
92
+ /** The lead object as published. Every writable field appears here. */ function leadView(siteId, doc) {
93
+ var _doc_data, _data_ownerUid, _data_notes, _data_unqualifiedReason, _data_company, _data_jobTitle, _data_phone, _data_website, _data_address, _data_leadSource, _data_convertedContactId, _data_companyId, _data_dealId;
94
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
95
+ // The lead's own silo — a group of one, however the org pools its
96
+ // contacts — which is where `addHostLead` recorded the basis.
97
+ const consent = readMarketingBasis(data, soloConsentGroup(siteId));
98
+ return _extends({
99
+ id: doc.id,
100
+ object: 'lead',
101
+ siteId,
102
+ email: typeof data['email'] === 'string' ? data['email'] : null,
103
+ name: typeof data['name'] === 'string' ? data['name'] : null,
104
+ status: crmLeadStatus(data),
105
+ ownerUid: (_data_ownerUid = data.ownerUid) != null ? _data_ownerUid : null,
106
+ notes: (_data_notes = data.notes) != null ? _data_notes : null,
107
+ unqualifiedReason: (_data_unqualifiedReason = data.unqualifiedReason) != null ? _data_unqualifiedReason : null,
108
+ // The lead's own profile (AGL-3231).
109
+ company: (_data_company = data.company) != null ? _data_company : null,
110
+ jobTitle: (_data_jobTitle = data.jobTitle) != null ? _data_jobTitle : null,
111
+ phone: (_data_phone = data.phone) != null ? _data_phone : null,
112
+ website: (_data_website = data.website) != null ? _data_website : null,
113
+ address: (_data_address = data.address) != null ? _data_address : null,
114
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
115
+ leadSource: (_data_leadSource = data.leadSource) != null ? _data_leadSource : null,
116
+ sources: Array.isArray(data['sources']) ? data['sources'].map(String) : [],
117
+ submissionCount: typeof data['submissionCount'] === 'number' ? data['submissionCount'] : 0,
118
+ firstSeen: isoFromMs(data['firstSeenAtMs']),
119
+ lastSeen: isoFromMs(data['lastSeenAtMs']),
120
+ marketingConsent: consent.basis === 'granted',
121
+ marketingConsentAt: consent.basis === 'granted' ? isoFromMs(consent.basisAtMs) : null,
122
+ convertedContactId: (_data_convertedContactId = data.convertedContactId) != null ? _data_convertedContactId : null,
123
+ convertedAt: isoFromMs(data.convertedAtMs),
124
+ companyId: (_data_companyId = data.companyId) != null ? _data_companyId : null,
125
+ dealId: (_data_dealId = data.dealId) != null ? _data_dealId : null,
126
+ // The org's own lead fields (AGL-3272) — `{}` on a lead that carries
127
+ // none, so a client can index it without a guard.
128
+ custom: crmCustomView(data)
129
+ }, crmTimes(data));
130
+ }
131
+ // ── The site ────────────────────────────────────────────────────────────────
132
+ /**
133
+ * The `siteId` a request names, validated — the query parameter first, the
134
+ * body as the fallback a `POST`/`PATCH` may use. Required, and required to
135
+ * be one of the organization's own sites, for the reason the module header
136
+ * gives.
137
+ */ function readLeadSite(ctx, url, body = {}) {
138
+ var _url_searchParams_get;
139
+ const refuse = (error)=>({
140
+ response: crmValidationFailed(ctx, 'lead', {
141
+ siteId: error
142
+ })
143
+ });
144
+ const raw = (_url_searchParams_get = url.searchParams.get('siteId')) != null ? _url_searchParams_get : body.siteId;
145
+ if (raw === undefined || raw === null) {
146
+ return refuse('Required — name the site the lead belongs to');
147
+ }
148
+ const siteId = String(raw).trim();
149
+ if (!siteId) return refuse('Must name a site');
150
+ if (!orgOwnsHost(ctx, siteId)) return refuse('No such site in this organization');
151
+ return {
152
+ siteId
153
+ };
154
+ }
155
+ /**
156
+ * The org's leads (AGL-3275). The site is still named by every caller — a
157
+ * lead is created and listed in a site's context — but it selects by
158
+ * `visibleTo` now rather than by the collection it lives in, so
159
+ * {@link leadsVisibleTo} is what a LIST must use. This bare ref is for
160
+ * `doc(id)` reads and writes, which the route authorizes for itself.
161
+ */ function leadsCollection(ctx, _siteId) {
162
+ return ctx.firestore.collection('orgs').doc(ctx.orgId).collection('leads');
163
+ }
164
+ /** The same collection, narrowed to the leads one site may see. */ function leadsVisibleTo(ctx, siteId) {
165
+ return leadsCollection(ctx, siteId).where('visibleTo', 'array-contains-any', scopeTokensForHost(siteId));
166
+ }
167
+ // ── The owner ───────────────────────────────────────────────────────────────
168
+ /**
169
+ * The owner a body names, by uid or by address, resolved to a uid on the
170
+ * roster — or `null` to clear, or `undefined` when the body named nobody.
171
+ *
172
+ * `ownerEmail` is what a spreadsheet or a zap has; a uid is what the record
173
+ * stores. The address resolves the way the CRM's own import and automation
174
+ * steps resolve one (AGL-2614): against the roster alone, so an address the
175
+ * project's auth knows but the organization does not names nobody. One
176
+ * roster read, paid only when an address was given.
177
+ */ async function readOwner(ctx, body, errors) {
178
+ const ownerUid = readOptionalText(body, 'ownerUid', CRM_TITLE_MAX, errors);
179
+ const ownerEmail = readOptionalText(body, 'ownerEmail', CRM_TITLE_MAX, errors);
180
+ if (ownerUid !== undefined && ownerEmail !== undefined) {
181
+ errors.ownerEmail = 'Name the owner once — by ownerUid or by ownerEmail, not both';
182
+ return undefined;
183
+ }
184
+ if (ownerEmail === undefined) return ownerUid;
185
+ if (ownerEmail === null) return null;
186
+ const email = normalizeContactEmail(ownerEmail);
187
+ if (!email) {
188
+ errors.ownerEmail = 'Must be an email address';
189
+ return undefined;
190
+ }
191
+ const members = await listOrgMembers(ctx.orgId);
192
+ const roster = members.map((member)=>crmMemberOption(member)).filter((option)=>option !== null);
193
+ const found = findOrgMember(roster, email);
194
+ if (!found) {
195
+ errors.ownerEmail = 'No member of this organization has this address';
196
+ return undefined;
197
+ }
198
+ return found.uid;
199
+ }
200
+ // ── PATCH ───────────────────────────────────────────────────────────────────
201
+ /** The lead's own fields, writable on a create and a PATCH alike (AGL-3231). */ const LEAD_PROFILE_KEYS = [
202
+ 'company',
203
+ 'jobTitle',
204
+ 'phone',
205
+ 'website',
206
+ 'address',
207
+ 'tags',
208
+ 'leadSource'
209
+ ];
210
+ const LEAD_WRITABLE = new Set([
211
+ 'siteId',
212
+ 'status',
213
+ 'ownerUid',
214
+ 'ownerEmail',
215
+ 'notes',
216
+ 'unqualifiedReason',
217
+ // The org's own lead fields (AGL-3272), judged against the definitions
218
+ // whose object is `lead` — never read by `readLeadInput`, which knows
219
+ // nothing about the org's definitions, but named here so a body that
220
+ // carries one is not refused as an unknown key.
221
+ 'custom',
222
+ ...LEAD_PROFILE_KEYS
223
+ ]);
224
+ /** The profile as an update: a cleared field is deleted, not blanked. */ function profileUpdate(patch) {
225
+ const update = {};
226
+ for (const [key, value] of Object.entries(patch)){
227
+ if (value === undefined) continue;
228
+ update[key] = value === null ? FieldValue.delete() : value;
229
+ }
230
+ return update;
231
+ }
232
+ function readLeadInput(body) {
233
+ const errors = {};
234
+ const values = {
235
+ profile: {}
236
+ };
237
+ refuseUnknownKeys(body, LEAD_WRITABLE, 'lead', errors);
238
+ const profileBody = {};
239
+ for (const key of LEAD_PROFILE_KEYS){
240
+ if (key in body) profileBody[key] = body[key];
241
+ }
242
+ const profile = normalizeCrmLeadProfile(profileBody);
243
+ Object.assign(errors, profile.errors);
244
+ values.profile = profile.patch;
245
+ if (body.status !== undefined) {
246
+ if (body.status === 'qualified') {
247
+ errors.status = 'Not settable — a lead is qualified by converting it: POST /v1/leads/{id}/convert';
248
+ } else if (typeof body.status === 'string' && LEAD_PATCH_STATUSES.includes(body.status)) {
249
+ values.status = body.status;
250
+ } else {
251
+ errors.status = `Must be one of: ${LEAD_PATCH_STATUSES.join(', ')}`;
252
+ }
253
+ }
254
+ const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors);
255
+ if (notes !== undefined) values.notes = notes;
256
+ const reason = readOptionalText(body, 'unqualifiedReason', CRM_TEXT_MAX, errors);
257
+ if (reason !== undefined) values.unqualifiedReason = reason;
258
+ return Object.keys(errors).length ? {
259
+ errors
260
+ } : {
261
+ values
262
+ };
263
+ }
264
+ /** `PATCH /v1/leads/{id}`. */ async function updateLead(request, ctx, url, leadId) {
265
+ var _snap_data;
266
+ const body = await readJsonBody(request);
267
+ const parsed = readLeadInput(body);
268
+ if ('errors' in parsed) return crmValidationFailed(ctx, 'lead', parsed.errors);
269
+ const site = readLeadSite(ctx, url, body);
270
+ if ('response' in site) return site.response;
271
+ const ref = leadsCollection(ctx, site.siteId).doc(leadId);
272
+ const snap = await ref.get();
273
+ if (!snap.exists) {
274
+ return ApiErrors.notFound({
275
+ message: 'No such lead',
276
+ headers: ctx.headers
277
+ });
278
+ }
279
+ const stored = (_snap_data = snap.data()) != null ? _snap_data : {};
280
+ const _parsed_values = parsed.values, { status, unqualifiedReason, profile } = _parsed_values, rest = _object_without_properties_loose(_parsed_values, [
281
+ "status",
282
+ "unqualifiedReason",
283
+ "profile"
284
+ ]);
285
+ if (status !== undefined && stored.convertedContactId) {
286
+ return ApiErrors.conflict({
287
+ message: 'This lead was converted; its status is fixed',
288
+ code: 'lead_converted',
289
+ headers: ctx.headers
290
+ });
291
+ }
292
+ const errors = {};
293
+ const ownerUid = await readOwner(ctx, body, errors);
294
+ if (ownerUid) Object.assign(errors, await memberError(ctx, 'ownerUid', ownerUid));
295
+ const current = crmLeadStatus(stored);
296
+ const next = status != null ? status : current;
297
+ /*
298
+ * A reason travels with the closed state and only with it. Sent beside
299
+ * `status: "unqualified"` it is required; sent on its own it changes the
300
+ * reason of a lead that is already unqualified; sent with any other
301
+ * status it describes a state the lead is not in.
302
+ */ if (next === 'unqualified') {
303
+ const reason = unqualifiedReason !== undefined ? unqualifiedReason : stored.unqualifiedReason;
304
+ if (!reason) {
305
+ errors.unqualifiedReason = 'A reason is required to mark a lead unqualified';
306
+ }
307
+ } else if (unqualifiedReason) {
308
+ errors.unqualifiedReason = 'Only an unqualified lead carries a reason';
309
+ }
310
+ /*
311
+ * THE ORG'S LEAD SOURCES (AGL-3298): a restricted picklist. An active
312
+ * value is stored as the list spells it; the value the lead already
313
+ * holds is kept even when it has since been deactivated; anything else
314
+ * is a 400 naming the values the list allows.
315
+ */ if (typeof profile.leadSource === 'string') {
316
+ const judged = judgeCrmLeadSource(await readOrgLeadSourcePicklist(ctx), profile.leadSource, stored.leadSource);
317
+ if (judged.ok === false) errors.leadSource = judged.error;
318
+ else profile.leadSource = judged.value;
319
+ }
320
+ if (Object.keys(errors).length) return crmValidationFailed(ctx, 'lead', errors);
321
+ // The org's own lead fields (AGL-3272), judged against the definitions
322
+ // whose object is `lead` — a contact field of the same key is a
323
+ // different field with a possibly different type.
324
+ const custom = await readCrmCustomBody(ctx, body, 'lead');
325
+ if (custom && 'errors' in custom) return crmValidationFailed(ctx, 'lead', custom.errors);
326
+ const update = _extends({}, updatePayload(_extends({}, rest, {
327
+ ownerUid
328
+ })), profileUpdate(profile), custom && 'values' in custom ? crmCustomUpdate(custom.values) : {});
329
+ if (status !== undefined && status !== current) update.status = status;
330
+ if (next === 'unqualified') {
331
+ if (unqualifiedReason !== undefined) update.unqualifiedReason = unqualifiedReason;
332
+ } else if (stored.unqualifiedReason !== undefined) {
333
+ // Reopening drops the reason with the closed state.
334
+ update.unqualifiedReason = FieldValue.delete();
335
+ }
336
+ if (Object.keys(update).length > 0) {
337
+ await ref.update(_extends({}, update, {
338
+ updatedAt: Timestamp.now()
339
+ }));
340
+ // What the console's Leads list searches and filters by (AGL-3321).
341
+ await restampCrmListFieldsAt(ref, 'leads');
342
+ }
343
+ return apiJson(leadView(site.siteId, await ref.get()), {
344
+ headers: ctx.headers
345
+ });
346
+ }
347
+ // ── Create ──────────────────────────────────────────────────────────────────
348
+ const LEAD_CREATE_STATUSES = [
349
+ 'new',
350
+ 'working'
351
+ ];
352
+ const LEAD_NAME_MAX = 120;
353
+ /** The surface a lead created over the API names, beside `signup`, `booking`, `form:{id}`, `import` and `manual`. */ const LEAD_API_SOURCE = 'api';
354
+ /**
355
+ * `POST /v1/leads` (AGL-3231). Accepts an `Idempotency-Key`, scoped to the
356
+ * site. Through the one lead door, so one address is one lead per site: a
357
+ * second create updates and answers `200`.
358
+ */ async function createLead(request, ctx, url) {
359
+ const body = await readJsonBody(request);
360
+ const errors = {};
361
+ refuseUnknownKeys(body, new Set([
362
+ ...LEAD_WRITABLE,
363
+ 'email',
364
+ 'name'
365
+ ]), 'lead', errors);
366
+ const email = normalizeContactEmail(body.email);
367
+ if (!email) errors.email = 'Required — an email address';
368
+ const name = readOptionalText(body, 'name', LEAD_NAME_MAX, errors);
369
+ const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors);
370
+ let status;
371
+ if (body.status !== undefined && body.status !== null) {
372
+ if (typeof body.status === 'string' && LEAD_CREATE_STATUSES.includes(body.status)) {
373
+ status = body.status;
374
+ } else {
375
+ errors.status = `Must be one of: ${LEAD_CREATE_STATUSES.join(', ')}`;
376
+ }
377
+ }
378
+ if (body.unqualifiedReason !== undefined) {
379
+ errors.unqualifiedReason = 'Not settable on a create — unqualify with a PATCH';
380
+ }
381
+ const profileBody = {};
382
+ for (const key of LEAD_PROFILE_KEYS){
383
+ if (key in body) profileBody[key] = body[key];
384
+ }
385
+ const profile = normalizeCrmLeadProfile(profileBody);
386
+ Object.assign(errors, profile.errors);
387
+ const site = readLeadSite(ctx, url, body);
388
+ if ('response' in site) {
389
+ if (Object.keys(errors).length) {
390
+ return crmValidationFailed(ctx, 'lead', _extends({}, errors, {
391
+ siteId: 'Required — name the site the lead belongs to'
392
+ }));
393
+ }
394
+ return site.response;
395
+ }
396
+ const ownerUid = await readOwner(ctx, body, errors);
397
+ if (ownerUid) Object.assign(errors, await memberError(ctx, 'ownerUid', ownerUid));
398
+ if (Object.keys(errors).length) return crmValidationFailed(ctx, 'lead', errors);
399
+ // Judged above the claim (AGL-3272), so a refused field costs no
400
+ // idempotency key — the shape the companies resource writes.
401
+ const custom = await readCrmCustomBody(ctx, body, 'lead');
402
+ if (custom && 'errors' in custom) return crmValidationFailed(ctx, 'lead', custom.errors);
403
+ const customValues = custom && 'values' in custom ? custom.values : {};
404
+ const claimed = await claimWrite(ctx, site.siteId, request.headers.get('Idempotency-Key'), 'lead-creates');
405
+ if ('replay' in claimed) return claimed.replay;
406
+ const { claim } = claimed;
407
+ try {
408
+ const hostRef = ctx.firestore.collection('hosts').doc(site.siteId);
409
+ const leadsRef = leadsCollection(ctx, site.siteId);
410
+ // Non-null by construction: the normalizer refused every address this
411
+ // derivation could not key.
412
+ const leadId = personKey(email);
413
+ const existing = await leadsRef.doc(leadId).get();
414
+ const created = !existing.exists;
415
+ /*
416
+ * THE ORG'S LEAD SOURCES (AGL-3298), judged once the lead's own value
417
+ * is known: a value outside the list is a 400 naming what it allows, a
418
+ * lead the site already held keeps its current value, and a new lead
419
+ * that named none starts from the list's default.
420
+ */ const leadSources = await readOrgLeadSourcePicklist(ctx);
421
+ if (typeof profile.patch.leadSource === 'string') {
422
+ const judged = judgeCrmLeadSource(leadSources, profile.patch.leadSource, existing.get('leadSource'));
423
+ if (judged.ok === false) {
424
+ await claim.release();
425
+ return crmValidationFailed(ctx, 'lead', {
426
+ leadSource: judged.error
427
+ });
428
+ }
429
+ profile.patch.leadSource = judged.value;
430
+ } else if (profile.patch.leadSource === undefined && created) {
431
+ const fallback = crmPicklistDefaultLabel(leadSources);
432
+ if (fallback) profile.patch.leadSource = fallback;
433
+ }
434
+ if (created) {
435
+ // What the SITE may see (AGL-3275), matching `addHostLead`'s own count:
436
+ // an unscoped count would charge one site for a sibling's leads.
437
+ const used = (await leadsVisibleTo(ctx, site.siteId).count().get()).data().count;
438
+ if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {
439
+ await claim.release();
440
+ return ApiErrors.conflict({
441
+ message: 'This site is at the platform lead limit, so the lead was not ' + 'created. Remove some leads, or contact support if this is real traffic.',
442
+ code: 'lead_ceiling',
443
+ headers: ctx.headers
444
+ });
445
+ }
446
+ }
447
+ const stored = await addHostLead({
448
+ hostRef,
449
+ hostId: site.siteId,
450
+ lead: _extends({
451
+ email: email
452
+ }, name ? {
453
+ name
454
+ } : {}, {
455
+ source: LEAD_API_SOURCE
456
+ })
457
+ });
458
+ if (!stored) {
459
+ await claim.release();
460
+ return ApiErrors.conflict({
461
+ message: 'The lead could not be stored',
462
+ code: 'lead_not_stored',
463
+ headers: ctx.headers
464
+ });
465
+ }
466
+ const working = _extends({}, profileUpdate(profile.patch), status ? {
467
+ status
468
+ } : {}, ownerUid ? {
469
+ ownerUid
470
+ } : {}, notes ? {
471
+ notes
472
+ } : {}, Object.keys(customValues).length ? {
473
+ custom: customValues
474
+ } : {});
475
+ const ref = leadsRef.doc(leadId);
476
+ if (Object.keys(working).length) {
477
+ await ref.set(_extends({}, working, {
478
+ updatedAt: Timestamp.now()
479
+ }), {
480
+ merge: true
481
+ });
482
+ // What the console's Leads list searches and filters by (AGL-3321).
483
+ await restampCrmListFieldsAt(ref, 'leads');
484
+ }
485
+ const view = leadView(site.siteId, await ref.get());
486
+ await claim.record(created ? 201 : 200, view);
487
+ return apiJson(view, {
488
+ status: created ? 201 : 200,
489
+ headers: ctx.headers
490
+ });
491
+ } catch (error) {
492
+ await claim.release();
493
+ throw error;
494
+ }
495
+ }
496
+ // ── Convert ─────────────────────────────────────────────────────────────────
497
+ const CONVERT_KEYS = new Set([
498
+ 'siteId',
499
+ 'company',
500
+ 'deal',
501
+ 'ownerUid',
502
+ 'ownerEmail'
503
+ ]);
504
+ const CURRENCY_PATTERN = /^[a-z]{3}$/;
505
+ const isPlainObject = (value)=>Boolean(value) && typeof value === 'object' && !Array.isArray(value);
506
+ /**
507
+ * The convert body: `company` is `{ link }`, `{ create }` or `null`; `deal`
508
+ * is what to open, or `null`. Each key is named in full — `company.link`,
509
+ * `deal.amountCents` — so a refused nested field is the one the integrator
510
+ * sent.
511
+ */ function readConvertInput(body) {
512
+ const errors = {};
513
+ const plan = {};
514
+ refuseUnknownKeys(body, CONVERT_KEYS, 'conversion', errors);
515
+ if (body.company !== undefined && body.company !== null) {
516
+ if (!isPlainObject(body.company)) {
517
+ errors.company = 'Must be { "link": id }, { "create": { … } } or null';
518
+ } else {
519
+ const company = body.company;
520
+ const keys = Object.keys(company);
521
+ const shape = keys.length === 1 ? keys[0] : null;
522
+ if (shape === 'link') {
523
+ const link = company.link;
524
+ if (typeof link !== 'string' || !link.trim() || link.trim().length > CRM_ID_MAX) {
525
+ errors['company.link'] = 'Must be a company id';
526
+ } else {
527
+ plan.companyId = link.trim();
528
+ }
529
+ } else if (shape === 'create' && isPlainObject(company.create)) {
530
+ var _create_name;
531
+ const create = company.create;
532
+ for (const key of Object.keys(create)){
533
+ if (key !== 'name' && key !== 'domain') {
534
+ errors[`company.create.${key}`] = 'Not writable on a company created here';
535
+ }
536
+ }
537
+ const name = String((_create_name = create.name) != null ? _create_name : '').trim().slice(0, CRM_LABEL_MAX);
538
+ if (!name) errors['company.create.name'] = 'A name is required';
539
+ let domain = null;
540
+ if (create.domain !== undefined && create.domain !== null) {
541
+ const rawDomain = String(create.domain).trim();
542
+ domain = rawDomain ? normalizeCompanyDomain(rawDomain) : null;
543
+ if (rawDomain && !domain) {
544
+ errors['company.create.domain'] = 'Must be a domain, like acme.com';
545
+ }
546
+ }
547
+ if (name) plan.createCompany = {
548
+ name,
549
+ domain
550
+ };
551
+ } else {
552
+ errors.company = 'Must be exactly one of { "link": id } or { "create": { "name", "domain"? } }';
553
+ }
554
+ }
555
+ }
556
+ if (body.deal !== undefined && body.deal !== null) {
557
+ if (!isPlainObject(body.deal)) {
558
+ errors.deal = 'Must be an object, or null';
559
+ } else {
560
+ var _deal_title;
561
+ const deal = body.deal;
562
+ for (const key of Object.keys(deal)){
563
+ if (![
564
+ 'title',
565
+ 'amountCents',
566
+ 'currency',
567
+ 'stageId'
568
+ ].includes(key)) {
569
+ errors[`deal.${key}`] = 'Not writable on a deal opened here';
570
+ }
571
+ }
572
+ const title = String((_deal_title = deal.title) != null ? _deal_title : '').trim().slice(0, CRM_TITLE_MAX);
573
+ if (!title) errors['deal.title'] = 'A title is required';
574
+ let amountCents = null;
575
+ if (deal.amountCents !== undefined && deal.amountCents !== null) {
576
+ if (typeof deal.amountCents !== 'number' || !Number.isInteger(deal.amountCents) || deal.amountCents < 0) {
577
+ errors['deal.amountCents'] = 'Must be a whole number of cents, 0 or more';
578
+ } else {
579
+ amountCents = deal.amountCents;
580
+ }
581
+ }
582
+ let currency = 'usd';
583
+ if (deal.currency !== undefined && deal.currency !== null) {
584
+ const value = String(deal.currency).trim().toLowerCase();
585
+ if (CURRENCY_PATTERN.test(value)) currency = value;
586
+ else errors['deal.currency'] = 'Must be a three-letter ISO 4217 code, like usd';
587
+ }
588
+ let stageId;
589
+ if (deal.stageId !== undefined && deal.stageId !== null) {
590
+ if (typeof deal.stageId !== 'string' || !deal.stageId.trim()) {
591
+ errors['deal.stageId'] = 'Must be a stage id';
592
+ } else {
593
+ stageId = deal.stageId.trim().slice(0, CRM_ID_MAX);
594
+ }
595
+ }
596
+ if (title) plan.deal = _extends({
597
+ title,
598
+ amountCents,
599
+ currency
600
+ }, stageId ? {
601
+ stageId
602
+ } : {});
603
+ }
604
+ }
605
+ return Object.keys(errors).length ? {
606
+ errors
607
+ } : {
608
+ plan
609
+ };
610
+ }
611
+ /** `POST /v1/leads/{id}/convert`. */ async function convertLead(request, ctx, url, leadId) {
612
+ const body = await readJsonBody(request);
613
+ const parsed = readConvertInput(body);
614
+ if ('errors' in parsed) return crmValidationFailed(ctx, 'conversion', parsed.errors);
615
+ const site = readLeadSite(ctx, url, body);
616
+ if ('response' in site) return site.response;
617
+ const errors = {};
618
+ const ownerUid = await readOwner(ctx, body, errors);
619
+ if (ownerUid) Object.assign(errors, await memberError(ctx, 'ownerUid', ownerUid));
620
+ if (Object.keys(errors).length) return crmValidationFailed(ctx, 'conversion', errors);
621
+ const ref = leadsCollection(ctx, site.siteId).doc(leadId);
622
+ if (!(await ref.get()).exists) {
623
+ return ApiErrors.notFound({
624
+ message: 'No such lead',
625
+ headers: ctx.headers
626
+ });
627
+ }
628
+ // Scoped to the SITE: the lead is the site's record, and its id is only
629
+ // unique within the site — see the module header.
630
+ const claimed = await claimWrite(ctx, site.siteId, request.headers.get('Idempotency-Key'), 'lead-conversions');
631
+ if ('replay' in claimed) return claimed.replay;
632
+ const { claim } = claimed;
633
+ try {
634
+ var _result_companyId, _result_dealId;
635
+ const result = await convertHostLead(_extends({
636
+ firestore: ctx.firestore,
637
+ hostId: site.siteId,
638
+ orgId: ctx.orgId,
639
+ org: ctx.org,
640
+ leadId,
641
+ // A key has no uid; `'api'` is the attribution every REST create
642
+ // stamps, and a key owns nothing — see the runtime's header. Its name
643
+ // is what the site's activity feed shows for the conversion.
644
+ actor: {
645
+ uid: 'api',
646
+ email: null,
647
+ kind: 'api',
648
+ apiKeyName: ctx.keyName
649
+ }
650
+ }, ownerUid ? {
651
+ ownerUid
652
+ } : {}, parsed.plan));
653
+ if (result.ok === false) {
654
+ await claim.release();
655
+ return convertRefusal(ctx, result.reason);
656
+ }
657
+ const receipt = {
658
+ object: 'lead_conversion',
659
+ id: leadId,
660
+ siteId: site.siteId,
661
+ contactId: result.contactId,
662
+ companyId: (_result_companyId = result.companyId) != null ? _result_companyId : null,
663
+ dealId: (_result_dealId = result.dealId) != null ? _result_dealId : null,
664
+ alreadyConverted: result.alreadyConverted,
665
+ lead: leadView(site.siteId, await ref.get())
666
+ };
667
+ await claim.record(200, receipt);
668
+ return apiJson(receipt, {
669
+ status: result.alreadyConverted ? 200 : 201,
670
+ headers: ctx.headers
671
+ });
672
+ } catch (error) {
673
+ await claim.release();
674
+ throw error;
675
+ }
676
+ }
677
+ /**
678
+ * Each refusal the writes can answer, in the envelope this API publishes.
679
+ * A band is `plan_required` with the creates' own code, because on the one
680
+ * plan that refuses an upgrade is the whole remedy; the rest are states of
681
+ * the record, which is what `409 conflict` means here.
682
+ */ function convertRefusal(ctx, reason) {
683
+ switch(reason){
684
+ case 'unknown-lead':
685
+ return ApiErrors.notFound({
686
+ message: 'No such lead',
687
+ headers: ctx.headers
688
+ });
689
+ case 'unknown-company':
690
+ return crmValidationFailed(ctx, 'conversion', {
691
+ 'company.link': 'No such company in this organization'
692
+ });
693
+ case 'band-full':
694
+ return ApiErrors.planRequired({
695
+ message: 'CRM records limit reached across contacts, companies and deals — ' + 'the lead was not converted. Upgrade the plan to add more.',
696
+ code: 'crm_records_quota',
697
+ headers: ctx.headers
698
+ });
699
+ case 'no-email':
700
+ return ApiErrors.conflict({
701
+ message: 'This lead has no usable email address to convert',
702
+ code: 'lead_not_convertible',
703
+ headers: ctx.headers
704
+ });
705
+ case 'contact-not-created':
706
+ return ApiErrors.conflict({
707
+ message: 'The contact could not be created — the audience band may be full. ' + 'Nothing was changed.',
708
+ code: 'contact_not_created',
709
+ headers: ctx.headers
710
+ });
711
+ case 'erased':
712
+ return ApiErrors.conflict({
713
+ message: 'This person was erased from the organization at their request, so ' + 'a contact cannot be created for this address. Nothing was changed.',
714
+ code: 'person_erased',
715
+ headers: ctx.headers
716
+ });
717
+ case 'no-stages':
718
+ return ApiErrors.conflict({
719
+ message: 'The default pipeline has no stages to open a deal in',
720
+ code: 'pipeline_has_no_stages',
721
+ headers: ctx.headers
722
+ });
723
+ }
724
+ }
725
+ // ── List ────────────────────────────────────────────────────────────────────
726
+ /** The `lastSeen`-ordered cursor: `ms|id`. */ function encodeLeadCursor(lastSeenAtMs, id) {
727
+ return `${typeof lastSeenAtMs === 'number' ? lastSeenAtMs : 0}|${id}`;
728
+ }
729
+ function decodeLeadCursor(raw) {
730
+ if (!raw) return null;
731
+ const match = /^(\d+)\|(.+)$/.exec(raw);
732
+ return match ? {
733
+ lastSeenAtMs: Number(match[1]),
734
+ id: match[2]
735
+ } : null;
736
+ }
737
+ /** `GET /v1/leads`. */ async function listLeads(ctx, url) {
738
+ var _url_searchParams_get, _url_searchParams_get1;
739
+ const site = readLeadSite(ctx, url);
740
+ if ('response' in site) return site.response;
741
+ const rawStatus = ((_url_searchParams_get = url.searchParams.get('status')) != null ? _url_searchParams_get : '').trim();
742
+ if (rawStatus && !CRM_LEAD_STATUSES.includes(rawStatus)) {
743
+ return crmValidationFailed(ctx, 'lead filter', {
744
+ status: `Must be one of: ${CRM_LEAD_STATUSES.join(', ')}`
745
+ });
746
+ }
747
+ const status = rawStatus;
748
+ const ownerUid = ((_url_searchParams_get1 = url.searchParams.get('ownerUid')) != null ? _url_searchParams_get1 : '').trim().slice(0, CRM_ID_MAX);
749
+ const limit = parseLimit(url.searchParams.get('limit'));
750
+ let query = leadsVisibleTo(ctx, site.siteId).orderBy('lastSeenAtMs', 'desc').orderBy(FieldPath.documentId(), 'desc').limit(limit + 1);
751
+ const cursor = decodeLeadCursor(decodeCursor(url.searchParams.get('cursor')));
752
+ if (cursor) query = query.startAfter(cursor.lastSeenAtMs, cursor.id);
753
+ const snap = await query.get();
754
+ const docs = snap.docs.slice(0, limit);
755
+ const last = docs[docs.length - 1];
756
+ const nextCursor = snap.docs.length > limit && last ? encodeCursor(encodeLeadCursor(last.get('lastSeenAtMs'), last.id)) : null;
757
+ const page = docs.filter((doc)=>{
758
+ var _doc_data;
759
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
760
+ if (status && crmLeadStatus(data) !== status) return false;
761
+ if (ownerUid && data.ownerUid !== ownerUid) return false;
762
+ return true;
763
+ });
764
+ return listResponse(page.map((doc)=>leadView(site.siteId, doc)), nextCursor, ctx.headers);
765
+ }
766
+ // ── Dispatch ────────────────────────────────────────────────────────────────
767
+ export async function handleLeads(request, ctx, segments, url) {
768
+ const [, leadId, action] = segments;
769
+ if (!leadId) {
770
+ if (request.method === 'GET') {
771
+ const denied = requireScope(ctx, 'crm:read');
772
+ if (denied) return denied;
773
+ return listLeads(ctx, url);
774
+ }
775
+ if (request.method === 'POST') {
776
+ const denied = requireScope(ctx, 'crm:write');
777
+ if (denied) return denied;
778
+ return createLead(request, ctx, url);
779
+ }
780
+ return ApiErrors.methodNotAllowed({
781
+ headers: _extends({}, ctx.headers, {
782
+ Allow: 'GET, POST'
783
+ })
784
+ });
785
+ }
786
+ if (action === 'convert') {
787
+ if (request.method === 'POST') {
788
+ const denied = requireScope(ctx, 'crm:write');
789
+ if (denied) return denied;
790
+ return convertLead(request, ctx, url, leadId);
791
+ }
792
+ return ApiErrors.methodNotAllowed({
793
+ headers: _extends({}, ctx.headers, {
794
+ Allow: 'POST'
795
+ })
796
+ });
797
+ }
798
+ if (action !== undefined) {
799
+ return ApiErrors.notFound({
800
+ message: `Unknown endpoint: /v1/${segments.join('/')}`,
801
+ headers: ctx.headers
802
+ });
803
+ }
804
+ if (request.method === 'GET') {
805
+ const denied = requireScope(ctx, 'crm:read');
806
+ if (denied) return denied;
807
+ const site = readLeadSite(ctx, url);
808
+ if ('response' in site) return site.response;
809
+ const snap = await leadsCollection(ctx, site.siteId).doc(leadId).get();
810
+ if (!snap.exists) {
811
+ return ApiErrors.notFound({
812
+ message: 'No such lead',
813
+ headers: ctx.headers
814
+ });
815
+ }
816
+ return apiJson(leadView(site.siteId, snap), {
817
+ headers: ctx.headers
818
+ });
819
+ }
820
+ if (request.method === 'PATCH') {
821
+ const denied = requireScope(ctx, 'crm:write');
822
+ if (denied) return denied;
823
+ return updateLead(request, ctx, url, leadId);
824
+ }
825
+ return ApiErrors.methodNotAllowed({
826
+ headers: _extends({}, ctx.headers, {
827
+ Allow: 'GET, PATCH'
828
+ })
829
+ });
830
+ }
831
+
832
+ //# sourceMappingURL=crm-leads.js.map