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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (346) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -14
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/components/companies-bulk-bar.js +12 -4
  6. package/src/lib/components/companies-bulk-bar.js.map +1 -1
  7. package/src/lib/components/company-detail-page.js +11 -0
  8. package/src/lib/components/company-detail-page.js.map +1 -1
  9. package/src/lib/components/company-edit-drawer.js +12 -2
  10. package/src/lib/components/company-edit-drawer.js.map +1 -1
  11. package/src/lib/components/company-import-drawer.d.ts +1 -1
  12. package/src/lib/components/company-picker.js +10 -3
  13. package/src/lib/components/company-picker.js.map +1 -1
  14. package/src/lib/components/contact-associations-card.js +4 -3
  15. package/src/lib/components/contact-associations-card.js.map +1 -1
  16. package/src/lib/components/contact-detail-page.d.ts +4 -4
  17. package/src/lib/components/contact-detail-page.js +50 -53
  18. package/src/lib/components/contact-detail-page.js.map +1 -1
  19. package/src/lib/components/contact-list-columns.d.ts +3 -0
  20. package/src/lib/components/contact-list-columns.js +21 -5
  21. package/src/lib/components/contact-list-columns.js.map +1 -1
  22. package/src/lib/components/contact-merge-dialog.js +2 -1
  23. package/src/lib/components/contact-merge-dialog.js.map +1 -1
  24. package/src/lib/components/contact-timeline-card.js +4 -3
  25. package/src/lib/components/contact-timeline-card.js.map +1 -1
  26. package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
  27. package/src/lib/components/contacts-bulk-bar.js +24 -51
  28. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  29. package/src/lib/components/contacts-section.js +9 -3
  30. package/src/lib/components/contacts-section.js.map +1 -1
  31. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  32. package/src/lib/components/crm-booking-zone.js.map +1 -1
  33. package/src/lib/components/crm-export-all-button.d.ts +1 -1
  34. package/src/lib/components/crm-export-all-button.js.map +1 -1
  35. package/src/lib/components/crm-glance-card.js +2 -1
  36. package/src/lib/components/crm-glance-card.js.map +1 -1
  37. package/src/lib/components/crm-send-email-dialog.js +9 -6
  38. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  39. package/src/lib/components/csv-import-drawer.js +2 -2
  40. package/src/lib/components/csv-import-drawer.js.map +1 -1
  41. package/src/lib/components/deal-detail-page.js +18 -2
  42. package/src/lib/components/deal-detail-page.js.map +1 -1
  43. package/src/lib/components/deal-edit-drawer.js +10 -0
  44. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  45. package/src/lib/components/deal-products-card.js +27 -16
  46. package/src/lib/components/deal-products-card.js.map +1 -1
  47. package/src/lib/components/deals-bulk-bar.js +13 -5
  48. package/src/lib/components/deals-bulk-bar.js.map +1 -1
  49. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  50. package/src/lib/components/lead-campaigns-card.js +15 -8
  51. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  52. package/src/lib/components/lead-detail-page.js +25 -5
  53. package/src/lib/components/lead-detail-page.js.map +1 -1
  54. package/src/lib/components/lead-properties-card.js +10 -0
  55. package/src/lib/components/lead-properties-card.js.map +1 -1
  56. package/src/lib/components/lead-status-chip.js +3 -1
  57. package/src/lib/components/lead-status-chip.js.map +1 -1
  58. package/src/lib/components/lead-surfaces-note.js +4 -5
  59. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  60. package/src/lib/components/lead-unqualify-dialog.js +6 -0
  61. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  62. package/src/lib/components/leads-bulk-bar.js +34 -11
  63. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  64. package/src/lib/components/leads-section.js +41 -8
  65. package/src/lib/components/leads-section.js.map +1 -1
  66. package/src/lib/components/new-lead-drawer.js +5 -4
  67. package/src/lib/components/new-lead-drawer.js.map +1 -1
  68. package/src/lib/components/org-lead-surfaces-note.js +6 -2
  69. package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
  70. package/src/lib/components/recipes-card.js +9 -4
  71. package/src/lib/components/recipes-card.js.map +1 -1
  72. package/src/lib/components/record-sharing-card.d.ts +57 -0
  73. package/src/lib/components/record-sharing-card.js +244 -0
  74. package/src/lib/components/record-sharing-card.js.map +1 -0
  75. package/src/lib/components/reports/activity-card.js +4 -3
  76. package/src/lib/components/reports/activity-card.js.map +1 -1
  77. package/src/lib/components/reports/closed-deals-card.js +4 -3
  78. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  79. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  80. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  81. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  82. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  83. package/src/lib/components/reports/forecast-card.js +3 -2
  84. package/src/lib/components/reports/forecast-card.js.map +1 -1
  85. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  86. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  87. package/src/lib/components/reports/pipeline-card.js +4 -3
  88. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  89. package/src/lib/components/reports/report-format.d.ts +1 -1
  90. package/src/lib/components/reports/report-format.js.map +1 -1
  91. package/src/lib/components/reports/report-scope.d.ts +1 -1
  92. package/src/lib/components/reports/report-scope.js.map +1 -1
  93. package/src/lib/components/reports/source-conversion-card.js +2 -1
  94. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  95. package/src/lib/components/reports/tasks-card.js +2 -1
  96. package/src/lib/components/reports/tasks-card.js.map +1 -1
  97. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  98. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  99. package/src/lib/components/reports-section.js +4 -4
  100. package/src/lib/components/reports-section.js.map +1 -1
  101. package/src/lib/components/settings-section.js +7 -0
  102. package/src/lib/components/settings-section.js.map +1 -1
  103. package/src/lib/components/share-records-dialog.d.ts +28 -0
  104. package/src/lib/components/share-records-dialog.js +184 -0
  105. package/src/lib/components/share-records-dialog.js.map +1 -0
  106. package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
  107. package/src/lib/components/sharing-rule-drawer.js +374 -0
  108. package/src/lib/components/sharing-rule-drawer.js.map +1 -0
  109. package/src/lib/components/sharing-rules-card.d.ts +24 -0
  110. package/src/lib/components/sharing-rules-card.js +287 -0
  111. package/src/lib/components/sharing-rules-card.js.map +1 -0
  112. package/src/lib/components/use-crm-api.d.ts +1 -1
  113. package/src/lib/components/use-crm-api.js.map +1 -1
  114. package/src/lib/components/use-site-route-context.d.ts +25 -0
  115. package/src/lib/components/use-site-route-context.js +58 -0
  116. package/src/lib/components/use-site-route-context.js.map +1 -0
  117. package/src/lib/constants/api-routes.d.ts +31 -0
  118. package/src/lib/constants/api-routes.js +27 -1
  119. package/src/lib/constants/api-routes.js.map +1 -1
  120. package/src/lib/constants/bundle-common.d.ts +2 -0
  121. package/src/lib/constants/bundle-common.js +1 -0
  122. package/src/lib/constants/bundle-common.js.map +1 -1
  123. package/src/lib/declarations.console-server.d.ts +41 -0
  124. package/src/lib/declarations.console-server.js +115 -0
  125. package/src/lib/declarations.console-server.js.map +1 -0
  126. package/src/lib/declarations.server.d.ts +12 -0
  127. package/src/lib/declarations.server.js +84 -1
  128. package/src/lib/declarations.server.js.map +1 -1
  129. package/src/lib/hooks/use-contact-remove.d.ts +35 -0
  130. package/src/lib/hooks/use-contact-remove.js +77 -0
  131. package/src/lib/hooks/use-contact-remove.js.map +1 -0
  132. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  133. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  134. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  135. package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
  136. package/src/lib/hooks/use-crm-sharing.js +109 -0
  137. package/src/lib/hooks/use-crm-sharing.js.map +1 -0
  138. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  139. package/src/lib/model/campaign-filing-activity.js +1 -1
  140. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  141. package/src/lib/model/companies-csv.d.ts +1 -1
  142. package/src/lib/model/companies-csv.js +2 -2
  143. package/src/lib/model/companies-csv.js.map +1 -1
  144. package/src/lib/model/contact-holder.d.ts +68 -0
  145. package/src/lib/model/contact-holder.js +129 -0
  146. package/src/lib/model/contact-holder.js.map +1 -0
  147. package/src/lib/model/contact-interaction-links.d.ts +46 -0
  148. package/src/lib/model/contact-interaction-links.js +65 -0
  149. package/src/lib/model/contact-interaction-links.js.map +1 -0
  150. package/src/lib/model/contact-merge.d.ts +136 -0
  151. package/src/lib/model/contact-merge.js +412 -0
  152. package/src/lib/model/contact-merge.js.map +1 -0
  153. package/src/lib/model/contact-record.d.ts +7 -1
  154. package/src/lib/model/contact-record.js +7 -6
  155. package/src/lib/model/contact-record.js.map +1 -1
  156. package/src/lib/model/contact-remove.d.ts +48 -0
  157. package/src/lib/model/contact-remove.js +22 -0
  158. package/src/lib/model/contact-remove.js.map +1 -0
  159. package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
  160. package/src/lib/model/contacts-bulk-writes.js +2 -82
  161. package/src/lib/model/contacts-bulk-writes.js.map +1 -1
  162. package/src/lib/model/contacts-csv.d.ts +2 -2
  163. package/src/lib/model/contacts-csv.js +2 -2
  164. package/src/lib/model/contacts-csv.js.map +1 -1
  165. package/src/lib/model/crm-csv.d.ts +239 -0
  166. package/src/lib/model/crm-csv.js +467 -0
  167. package/src/lib/model/crm-csv.js.map +1 -0
  168. package/src/lib/model/crm-digest.d.ts +227 -0
  169. package/src/lib/model/crm-digest.js +336 -0
  170. package/src/lib/model/crm-digest.js.map +1 -0
  171. package/src/lib/model/crm-import.d.ts +7 -1
  172. package/src/lib/model/crm-import.js +2 -1
  173. package/src/lib/model/crm-import.js.map +1 -1
  174. package/src/lib/model/crm-inbound.d.ts +162 -0
  175. package/src/lib/model/crm-inbound.js +208 -0
  176. package/src/lib/model/crm-inbound.js.map +1 -0
  177. package/src/lib/model/crm-lead-import.d.ts +1 -1
  178. package/src/lib/model/crm-lead-import.js +4 -4
  179. package/src/lib/model/crm-lead-import.js.map +1 -1
  180. package/src/lib/model/crm-record-routes.d.ts +5 -0
  181. package/src/lib/model/crm-record-routes.js +7 -0
  182. package/src/lib/model/crm-record-routes.js.map +1 -1
  183. package/src/lib/model/crm-reports.d.ts +467 -0
  184. package/src/lib/model/crm-reports.js +667 -0
  185. package/src/lib/model/crm-reports.js.map +1 -0
  186. package/src/lib/model/crm-routes.d.ts +6 -5
  187. package/src/lib/model/crm-routes.js +7 -7
  188. package/src/lib/model/crm-routes.js.map +1 -1
  189. package/src/lib/model/crm-search-sources.d.ts +15 -0
  190. package/src/lib/model/crm-search-sources.js +162 -0
  191. package/src/lib/model/crm-search-sources.js.map +1 -0
  192. package/src/lib/model/crm-sharing-api.d.ts +32 -0
  193. package/src/lib/model/crm-sharing-api.js +59 -0
  194. package/src/lib/model/crm-sharing-api.js.map +1 -0
  195. package/src/lib/model/crm-sharing.d.ts +333 -0
  196. package/src/lib/model/crm-sharing.js +603 -0
  197. package/src/lib/model/crm-sharing.js.map +1 -0
  198. package/src/lib/model/deals-csv.d.ts +1 -1
  199. package/src/lib/model/deals-csv.js +4 -5
  200. package/src/lib/model/deals-csv.js.map +1 -1
  201. package/src/lib/model/lead-filters.d.ts +5 -4
  202. package/src/lib/model/lead-filters.js +7 -5
  203. package/src/lib/model/lead-filters.js.map +1 -1
  204. package/src/lib/model/leads-csv.d.ts +1 -1
  205. package/src/lib/model/leads-csv.js +1 -1
  206. package/src/lib/model/leads-csv.js.map +1 -1
  207. package/src/lib/model/tasks-csv.d.ts +1 -1
  208. package/src/lib/model/tasks-csv.js +1 -1
  209. package/src/lib/model/tasks-csv.js.map +1 -1
  210. package/src/lib/plan-entitlements.d.ts +75 -0
  211. package/src/lib/plan-entitlements.js +56 -0
  212. package/src/lib/plan-entitlements.js.map +1 -0
  213. package/src/lib/plugin.js +39 -0
  214. package/src/lib/plugin.js.map +1 -1
  215. package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
  216. package/src/lib/server/api-v1/contacts-merge.js +120 -0
  217. package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
  218. package/src/lib/server/api-v1/contacts.d.ts +18 -0
  219. package/src/lib/server/api-v1/contacts.js +1076 -0
  220. package/src/lib/server/api-v1/contacts.js.map +1 -0
  221. package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
  222. package/src/lib/server/api-v1/crm-activities.js +269 -0
  223. package/src/lib/server/api-v1/crm-activities.js.map +1 -0
  224. package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
  225. package/src/lib/server/api-v1/crm-companies.js +391 -0
  226. package/src/lib/server/api-v1/crm-companies.js.map +1 -0
  227. package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
  228. package/src/lib/server/api-v1/crm-deals.js +631 -0
  229. package/src/lib/server/api-v1/crm-deals.js.map +1 -0
  230. package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
  231. package/src/lib/server/api-v1/crm-email-templates.js +336 -0
  232. package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
  233. package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
  234. package/src/lib/server/api-v1/crm-leads.js +833 -0
  235. package/src/lib/server/api-v1/crm-leads.js.map +1 -0
  236. package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
  237. package/src/lib/server/api-v1/crm-pipelines.js +146 -0
  238. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
  239. package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
  240. package/src/lib/server/api-v1/crm-shared.js +436 -0
  241. package/src/lib/server/api-v1/crm-shared.js.map +1 -0
  242. package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
  243. package/src/lib/server/api-v1/crm-tasks.js +398 -0
  244. package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
  245. package/src/lib/server/api-v1/openapi.d.ts +29 -0
  246. package/src/lib/server/api-v1/openapi.js +985 -0
  247. package/src/lib/server/api-v1/openapi.js.map +1 -0
  248. package/src/lib/server/api-v1/usage.d.ts +26 -0
  249. package/src/lib/server/api-v1/usage.js +73 -0
  250. package/src/lib/server/api-v1/usage.js.map +1 -0
  251. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  252. package/src/lib/server/campaign-filing-activity.js +7 -11
  253. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  254. package/src/lib/server/capture-contact.js +15 -4
  255. package/src/lib/server/capture-contact.js.map +1 -1
  256. package/src/lib/server/consent-group-participant.js +1 -1
  257. package/src/lib/server/consent-group-participant.js.map +1 -1
  258. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  259. package/src/lib/server/contact-email-engagement.js +146 -0
  260. package/src/lib/server/contact-email-engagement.js.map +1 -0
  261. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  262. package/src/lib/server/contact-lifecycle-floor.js +91 -0
  263. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  264. package/src/lib/server/contact-remove.d.ts +75 -0
  265. package/src/lib/server/contact-remove.js +244 -0
  266. package/src/lib/server/contact-remove.js.map +1 -0
  267. package/src/lib/server/contact-update.js +4 -3
  268. package/src/lib/server/contact-update.js.map +1 -1
  269. package/src/lib/server/contacts-import.d.ts +47 -11
  270. package/src/lib/server/contacts-import.js +171 -71
  271. package/src/lib/server/contacts-import.js.map +1 -1
  272. package/src/lib/server/contacts-merge.d.ts +1 -1
  273. package/src/lib/server/contacts-merge.js +3 -2
  274. package/src/lib/server/contacts-merge.js.map +1 -1
  275. package/src/lib/server/container-detach.d.ts +31 -0
  276. package/src/lib/server/container-detach.js +115 -0
  277. package/src/lib/server/container-detach.js.map +1 -0
  278. package/src/lib/server/convert-host-lead.d.ts +189 -0
  279. package/src/lib/server/convert-host-lead.js +518 -0
  280. package/src/lib/server/convert-host-lead.js.map +1 -0
  281. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  282. package/src/lib/server/crm-inbound-email.js +259 -0
  283. package/src/lib/server/crm-inbound-email.js.map +1 -0
  284. package/src/lib/server/crm-records-meter.d.ts +50 -0
  285. package/src/lib/server/crm-records-meter.js +126 -0
  286. package/src/lib/server/crm-records-meter.js.map +1 -0
  287. package/src/lib/server/crm-sharing.d.ts +108 -0
  288. package/src/lib/server/crm-sharing.js +790 -0
  289. package/src/lib/server/crm-sharing.js.map +1 -0
  290. package/src/lib/server/daily-digest-route.d.ts +101 -0
  291. package/src/lib/server/daily-digest-route.js +578 -0
  292. package/src/lib/server/daily-digest-route.js.map +1 -0
  293. package/src/lib/server/export-route.d.ts +60 -0
  294. package/src/lib/server/export-route.js +447 -0
  295. package/src/lib/server/export-route.js.map +1 -0
  296. package/src/lib/server/inbound-address.js +5 -3
  297. package/src/lib/server/inbound-address.js.map +1 -1
  298. package/src/lib/server/inbound-route.d.ts +74 -0
  299. package/src/lib/server/inbound-route.js +260 -0
  300. package/src/lib/server/inbound-route.js.map +1 -0
  301. package/src/lib/server/lead-campaign-carry.js +3 -3
  302. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  303. package/src/lib/server/lead-convert.d.ts +1 -1
  304. package/src/lib/server/lead-convert.js +2 -2
  305. package/src/lib/server/lead-convert.js.map +1 -1
  306. package/src/lib/server/lead-create.d.ts +1 -1
  307. package/src/lib/server/lead-create.js +10 -15
  308. package/src/lib/server/lead-create.js.map +1 -1
  309. package/src/lib/server/lead-nurturing.d.ts +23 -0
  310. package/src/lib/server/lead-nurturing.js +82 -0
  311. package/src/lib/server/lead-nurturing.js.map +1 -0
  312. package/src/lib/server/leads-import.js +7 -7
  313. package/src/lib/server/leads-import.js.map +1 -1
  314. package/src/lib/server/merge-contacts.d.ts +55 -0
  315. package/src/lib/server/merge-contacts.js +274 -0
  316. package/src/lib/server/merge-contacts.js.map +1 -0
  317. package/src/lib/server/pipeline-record-index.d.ts +36 -0
  318. package/src/lib/server/pipeline-record-index.js +87 -0
  319. package/src/lib/server/pipeline-record-index.js.map +1 -0
  320. package/src/lib/server/record-email-state.js +23 -0
  321. package/src/lib/server/record-email-state.js.map +1 -1
  322. package/src/lib/server/record-facts.js +2 -1
  323. package/src/lib/server/record-facts.js.map +1 -1
  324. package/src/lib/server/record-timeline.d.ts +12 -0
  325. package/src/lib/server/record-timeline.js +127 -15
  326. package/src/lib/server/record-timeline.js.map +1 -1
  327. package/src/lib/server/task-reminders-route.d.ts +82 -0
  328. package/src/lib/server/task-reminders-route.js +499 -0
  329. package/src/lib/server/task-reminders-route.js.map +1 -0
  330. package/src/lib/server/task-routes.d.ts +2 -0
  331. package/src/lib/server/task-routes.js +22 -3
  332. package/src/lib/server/task-routes.js.map +1 -1
  333. package/src/lib/server-deal-stage.js +2 -1
  334. package/src/lib/server-deal-stage.js.map +1 -1
  335. package/src/lib/server.d.ts +5 -2
  336. package/src/lib/server.js +47 -2
  337. package/src/lib/server.js.map +1 -1
  338. package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
  339. package/src/lib/testing/stand-in-site-record-routes.js +55 -0
  340. package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
  341. package/src/lib/usage-axes.d.ts +35 -0
  342. package/src/lib/usage-axes.js +75 -0
  343. package/src/lib/usage-axes.js.map +1 -0
  344. package/src/lib/components/use-emails-hub-path.d.ts +0 -24
  345. package/src/lib/components/use-emails-hub-path.js +0 -59
  346. package/src/lib/components/use-emails-hub-path.js.map +0 -1
@@ -34,7 +34,7 @@
34
34
  * otherwise decide about.
35
35
  */
36
36
  import { type PluginApiHandler } from '@aglyn/aglyn/server';
37
- export { stageForNewDeal } from '@aglyn/tenant-runtime/convert-host-lead';
37
+ export { stageForNewDeal } from './convert-host-lead';
38
38
  /** The body the console's convert dialog posts. */
39
39
  export interface LeadConvertRequest {
40
40
  hostId: string;
@@ -34,13 +34,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
34
34
  * otherwise decide about.
35
35
  */ import { CONTACT_ERASED_MESSAGE, CRM_RECORDS_BAND_FULL_MESSAGE, normalizeCompanyDomain } from "@aglyn/aglyn/server";
36
36
  import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
37
- import { convertHostLead } from "@aglyn/tenant-runtime/convert-host-lead";
37
+ import { convertHostLead } from "./convert-host-lead.js";
38
38
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
39
39
  import { holdsDataManage } from "./org-caller.js";
40
40
  import { crmSuiteRefusal } from "./suite-gate.js";
41
41
  // The stage picker moved to the runtime with the writes; re-exported so the
42
42
  // pipeline code that imports it from here keeps its import.
43
- export { stageForNewDeal } from "@aglyn/tenant-runtime/convert-host-lead";
43
+ export { stageForNewDeal } from "./convert-host-lead.js";
44
44
  const COMPANY_NAME_MAX = 120;
45
45
  const DEAL_TITLE_MAX = 200;
46
46
  const CURRENCY_PATTERN = /^[a-z]{3}$/;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-convert.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/lead-convert` — a lead becomes a contact, and optionally a\n * company and a deal (AGL-2608).\n *\n * This is the console's door onto `convertHostLead` (the tenancy runtime),\n * which owns the four writes, their order, and the idempotency on the lead;\n * the REST API's `POST /v1/leads/{id}/convert` is the other door onto the\n * same function (AGL-2627). What lives here is what only this door knows:\n * the ID token, the body the convert dialog posts, and the statuses and\n * sentences the dialog shows.\n *\n * ## What the caller has to be\n *\n * A signed-in member of the host's org holding `data.manage` — the same key\n * the console gate on the CRM surface enforces — with a role on this site.\n * Checked here with the Admin SDK rather than trusted from the client,\n * because this route writes into collections the caller's own rules would\n * otherwise decide about.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n CRM_RECORDS_BAND_FULL_MESSAGE,\n normalizeCompanyDomain,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport {\n type ConvertHostLeadRefusal,\n convertHostLead,\n} from '@aglyn/tenant-runtime/convert-host-lead'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n// The stage picker moved to the runtime with the writes; re-exported so the\n// pipeline code that imports it from here keeps its import.\nexport { stageForNewDeal } from '@aglyn/tenant-runtime/convert-host-lead'\n\n/** The body the console's convert dialog posts. */\nexport interface LeadConvertRequest {\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those the caller — somebody\n * converted this person, and a record with no owner is one nobody\n * follows up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. */\n createCompany?: { name: string; domain?: string }\n /** Open a deal in the org's default pipeline. */\n deal?: {\n title: string\n amountCents?: number\n /** Lowercase ISO 4217; `usd` when absent or malformed. */\n currency?: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n }\n}\n\n/** What the route answers on success. */\nexport interface LeadConvertResponse {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n}\n\nconst COMPANY_NAME_MAX = 120\nconst DEAL_TITLE_MAX = 200\nconst CURRENCY_PATTERN = /^[a-z]{3}$/\n\n/**\n * How each refusal the writes can answer reads at this door — the status\n * and the sentence the convert dialog has always shown for it.\n */\nconst REFUSALS: Record<\n ConvertHostLeadRefusal,\n { status: number; body: Record<string, unknown> }\n> = {\n 'unknown-lead': { status: 404, body: { error: 'Unknown lead' } },\n 'no-email': {\n status: 422,\n body: { error: 'This lead has no usable email address to convert' },\n },\n 'contact-not-created': {\n status: 409,\n body: {\n error:\n 'The contact could not be created — the audience band may be ' +\n 'full. Nothing was changed.',\n },\n },\n 'band-full': {\n status: 409,\n body: { error: CRM_RECORDS_BAND_FULL_MESSAGE, reason: 'band' },\n },\n // The contact create route's own answer for an erased address (AGL-2623),\n // so the convert dialog and the add-contact drawer say one thing about\n // one person.\n erased: {\n status: 409,\n body: { error: CONTACT_ERASED_MESSAGE, reason: 'erased' },\n },\n 'unknown-company': { status: 404, body: { error: 'Unknown company' } },\n 'no-stages': {\n status: 409,\n body: { error: 'The default pipeline has no stages to open a deal in' },\n },\n}\n\nexport const leadConvertHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadConvertRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n if (!hostId || !leadId) {\n res.status(400).json({ error: 'Missing hostId or leadId' })\n return\n }\n\n /*\n * Validate the optional parts BEFORE any read, so a malformed request costs\n * nothing and a bad company domain is refused rather than silently dropped\n * — a converter who typed a domain expects the company to carry it.\n */\n const requestedCompanyId = String(body.companyId ?? '').trim()\n let createCompany: { name: string; domain: string | null } | null = null\n if (!requestedCompanyId && body.createCompany) {\n const name = String(body.createCompany.name ?? '')\n .trim()\n .slice(0, COMPANY_NAME_MAX)\n if (!name) {\n res.status(400).json({ error: 'A company needs a name' })\n return\n }\n const rawDomain = String(body.createCompany.domain ?? '').trim()\n const domain = rawDomain ? normalizeCompanyDomain(rawDomain) : null\n if (rawDomain && !domain) {\n res.status(400).json({ error: 'That does not look like a domain' })\n return\n }\n createCompany = { name, domain }\n }\n let deal: {\n title: string\n amountCents: number | null\n currency: string\n stageId: string | undefined\n } | null = null\n if (body.deal) {\n const title = String(body.deal.title ?? '')\n .trim()\n .slice(0, DEAL_TITLE_MAX)\n if (!title) {\n res.status(400).json({ error: 'A deal needs a title' })\n return\n }\n const amount = Number(body.deal.amountCents)\n const currency = String(body.deal.currency ?? '')\n .trim()\n .toLowerCase()\n deal = {\n title,\n amountCents:\n Number.isFinite(amount) && amount >= 0 ? Math.round(amount) : null,\n currency: CURRENCY_PATTERN.test(currency) ? currency : 'usd',\n stageId: body.deal.stageId ? String(body.deal.stageId) : undefined,\n }\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n /*\n * The console's own gate on the CRM is `data.manage`, asked of the\n * permission catalog (`holdsDataManage`), and additionally that the\n * caller has a role on THIS site — an org member scoped to a sibling site\n * holds the key but not the host, and a lead is the host's record.\n * `resolveOrgPermissions` answers the site role and fails closed on a\n * lookup error when a host is named, so an absent membership refuses.\n */\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Converting a lead requires the data permission on this site' })\n return\n }\n\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const { orgId, org } = resolved\n // A lead's conversion is the CRM's, like its status and owner (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Converting a lead')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const result = await convertHostLead({\n firestore: firebaseAdmin.app().firestore(),\n hostId,\n orgId,\n org: org as Record<string, unknown>,\n leadId,\n actor: { uid: decoded.uid, email: decoded.email ?? null, kind: 'member' },\n ...(body.ownerUid ? { ownerUid: String(body.ownerUid) } : {}),\n ...(requestedCompanyId ? { companyId: requestedCompanyId } : {}),\n createCompany,\n deal,\n })\n if (result.ok === false) {\n const refusal = REFUSALS[result.reason]\n res.status(refusal.status).json(refusal.body)\n return\n }\n const answer: LeadConvertResponse = {\n ok: true,\n contactId: result.contactId,\n ...(result.companyId ? { companyId: result.companyId } : {}),\n ...(result.dealId ? { dealId: result.dealId } : {}),\n alreadyConverted: result.alreadyConverted,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('lead-convert failed', error)\n res.status(500).json({ error: 'The lead could not be converted.' })\n }\n}\n\nexport default leadConvertHandler\n"],"names":["CONTACT_ERASED_MESSAGE","CRM_RECORDS_BAND_FULL_MESSAGE","normalizeCompanyDomain","firebaseAdmin","getOrgForHost","convertHostLead","resolveOrgPermissions","holdsDataManage","crmSuiteRefusal","stageForNewDeal","COMPANY_NAME_MAX","DEAL_TITLE_MAX","CURRENCY_PATTERN","REFUSALS","status","body","error","reason","erased","leadConvertHandler","req","res","method","setHeader","json","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","trim","leadId","requestedCompanyId","companyId","createCompany","name","rawDomain","domain","deal","title","amount","Number","amountCents","currency","toLowerCase","isFinite","Math","round","test","stageId","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","org","suite","result","firestore","actor","email","kind","ownerUid","ok","refusal","answer","contactId","dealId","alreadyConverted","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-convert.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/lead-convert` — a lead becomes a contact, and optionally a\n * company and a deal (AGL-2608).\n *\n * This is the console's door onto `convertHostLead` (the tenancy runtime),\n * which owns the four writes, their order, and the idempotency on the lead;\n * the REST API's `POST /v1/leads/{id}/convert` is the other door onto the\n * same function (AGL-2627). What lives here is what only this door knows:\n * the ID token, the body the convert dialog posts, and the statuses and\n * sentences the dialog shows.\n *\n * ## What the caller has to be\n *\n * A signed-in member of the host's org holding `data.manage` — the same key\n * the console gate on the CRM surface enforces — with a role on this site.\n * Checked here with the Admin SDK rather than trusted from the client,\n * because this route writes into collections the caller's own rules would\n * otherwise decide about.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n CRM_RECORDS_BAND_FULL_MESSAGE,\n normalizeCompanyDomain,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport {\n type ConvertHostLeadRefusal,\n convertHostLead,\n} from './convert-host-lead'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n// The stage picker moved to the runtime with the writes; re-exported so the\n// pipeline code that imports it from here keeps its import.\nexport { stageForNewDeal } from './convert-host-lead'\n\n/** The body the console's convert dialog posts. */\nexport interface LeadConvertRequest {\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those the caller — somebody\n * converted this person, and a record with no owner is one nobody\n * follows up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. */\n createCompany?: { name: string; domain?: string }\n /** Open a deal in the org's default pipeline. */\n deal?: {\n title: string\n amountCents?: number\n /** Lowercase ISO 4217; `usd` when absent or malformed. */\n currency?: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n }\n}\n\n/** What the route answers on success. */\nexport interface LeadConvertResponse {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n}\n\nconst COMPANY_NAME_MAX = 120\nconst DEAL_TITLE_MAX = 200\nconst CURRENCY_PATTERN = /^[a-z]{3}$/\n\n/**\n * How each refusal the writes can answer reads at this door — the status\n * and the sentence the convert dialog has always shown for it.\n */\nconst REFUSALS: Record<\n ConvertHostLeadRefusal,\n { status: number; body: Record<string, unknown> }\n> = {\n 'unknown-lead': { status: 404, body: { error: 'Unknown lead' } },\n 'no-email': {\n status: 422,\n body: { error: 'This lead has no usable email address to convert' },\n },\n 'contact-not-created': {\n status: 409,\n body: {\n error:\n 'The contact could not be created — the audience band may be ' +\n 'full. Nothing was changed.',\n },\n },\n 'band-full': {\n status: 409,\n body: { error: CRM_RECORDS_BAND_FULL_MESSAGE, reason: 'band' },\n },\n // The contact create route's own answer for an erased address (AGL-2623),\n // so the convert dialog and the add-contact drawer say one thing about\n // one person.\n erased: {\n status: 409,\n body: { error: CONTACT_ERASED_MESSAGE, reason: 'erased' },\n },\n 'unknown-company': { status: 404, body: { error: 'Unknown company' } },\n 'no-stages': {\n status: 409,\n body: { error: 'The default pipeline has no stages to open a deal in' },\n },\n}\n\nexport const leadConvertHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadConvertRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n if (!hostId || !leadId) {\n res.status(400).json({ error: 'Missing hostId or leadId' })\n return\n }\n\n /*\n * Validate the optional parts BEFORE any read, so a malformed request costs\n * nothing and a bad company domain is refused rather than silently dropped\n * — a converter who typed a domain expects the company to carry it.\n */\n const requestedCompanyId = String(body.companyId ?? '').trim()\n let createCompany: { name: string; domain: string | null } | null = null\n if (!requestedCompanyId && body.createCompany) {\n const name = String(body.createCompany.name ?? '')\n .trim()\n .slice(0, COMPANY_NAME_MAX)\n if (!name) {\n res.status(400).json({ error: 'A company needs a name' })\n return\n }\n const rawDomain = String(body.createCompany.domain ?? '').trim()\n const domain = rawDomain ? normalizeCompanyDomain(rawDomain) : null\n if (rawDomain && !domain) {\n res.status(400).json({ error: 'That does not look like a domain' })\n return\n }\n createCompany = { name, domain }\n }\n let deal: {\n title: string\n amountCents: number | null\n currency: string\n stageId: string | undefined\n } | null = null\n if (body.deal) {\n const title = String(body.deal.title ?? '')\n .trim()\n .slice(0, DEAL_TITLE_MAX)\n if (!title) {\n res.status(400).json({ error: 'A deal needs a title' })\n return\n }\n const amount = Number(body.deal.amountCents)\n const currency = String(body.deal.currency ?? '')\n .trim()\n .toLowerCase()\n deal = {\n title,\n amountCents:\n Number.isFinite(amount) && amount >= 0 ? Math.round(amount) : null,\n currency: CURRENCY_PATTERN.test(currency) ? currency : 'usd',\n stageId: body.deal.stageId ? String(body.deal.stageId) : undefined,\n }\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n /*\n * The console's own gate on the CRM is `data.manage`, asked of the\n * permission catalog (`holdsDataManage`), and additionally that the\n * caller has a role on THIS site — an org member scoped to a sibling site\n * holds the key but not the host, and a lead is the host's record.\n * `resolveOrgPermissions` answers the site role and fails closed on a\n * lookup error when a host is named, so an absent membership refuses.\n */\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Converting a lead requires the data permission on this site' })\n return\n }\n\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const { orgId, org } = resolved\n // A lead's conversion is the CRM's, like its status and owner (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Converting a lead')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const result = await convertHostLead({\n firestore: firebaseAdmin.app().firestore(),\n hostId,\n orgId,\n org: org as Record<string, unknown>,\n leadId,\n actor: { uid: decoded.uid, email: decoded.email ?? null, kind: 'member' },\n ...(body.ownerUid ? { ownerUid: String(body.ownerUid) } : {}),\n ...(requestedCompanyId ? { companyId: requestedCompanyId } : {}),\n createCompany,\n deal,\n })\n if (result.ok === false) {\n const refusal = REFUSALS[result.reason]\n res.status(refusal.status).json(refusal.body)\n return\n }\n const answer: LeadConvertResponse = {\n ok: true,\n contactId: result.contactId,\n ...(result.companyId ? { companyId: result.companyId } : {}),\n ...(result.dealId ? { dealId: result.dealId } : {}),\n alreadyConverted: result.alreadyConverted,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('lead-convert failed', error)\n res.status(500).json({ error: 'The lead could not be converted.' })\n }\n}\n\nexport default leadConvertHandler\n"],"names":["CONTACT_ERASED_MESSAGE","CRM_RECORDS_BAND_FULL_MESSAGE","normalizeCompanyDomain","firebaseAdmin","getOrgForHost","convertHostLead","resolveOrgPermissions","holdsDataManage","crmSuiteRefusal","stageForNewDeal","COMPANY_NAME_MAX","DEAL_TITLE_MAX","CURRENCY_PATTERN","REFUSALS","status","body","error","reason","erased","leadConvertHandler","req","res","method","setHeader","json","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","trim","leadId","requestedCompanyId","companyId","createCompany","name","rawDomain","domain","deal","title","amount","Number","amountCents","currency","toLowerCase","isFinite","Math","round","test","stageId","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","org","suite","result","firestore","actor","email","kind","ownerUid","ok","refusal","answer","contactId","dealId","alreadyConverted","console"],"mappings":"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"}
@@ -77,7 +77,7 @@ export interface LeadCreateRequest {
77
77
  export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked doesn't exist any more. Pick it again.";
78
78
  /**
79
79
  * The campaigns a request may file a lead under: the organization's own
80
- * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A
80
+ * live containers of the `campaign` kind, and nothing else. A
81
81
  * request can claim any id, and a lead filed under another org's campaign
82
82
  * — or one the console soft-deleted — would sit on a page nobody in this
83
83
  * org can open. A lead is an org record, so any live campaign of the org
@@ -47,7 +47,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
47
47
  */ import { checkVisitorRecordCeiling, CONTACT_FIELDS_MAX_PER_ORG, CRM_COLLECTIONS, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey, readCrmCustomInput } from "@aglyn/aglyn/server";
48
48
  import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity, orgLeadsForHost, restampCrmListFieldsAt, readLeadForHost, scopedToHost } from "@aglyn/tenant-data-admin";
49
49
  import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
50
- import { normalizeCampaignIds, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
50
+ import { normalizeContainerIds, readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
51
+ import { readOrgContainers } from "@aglyn/tenant-data-admin/server/org-containers";
51
52
  import { FieldValue } from "firebase-admin/firestore";
52
53
  import { fileCampaignFilingActivities } from "./campaign-filing-activity.js";
53
54
  import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-picklist.js";
@@ -63,7 +64,7 @@ const NOTES_MAX = 4000;
63
64
  /** The sentence a campaign that is not the org's live one is refused with, under the field. */ export const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked doesn't exist any more. Pick it again.";
64
65
  /**
65
66
  * The campaigns a request may file a lead under: the organization's own
66
- * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A
67
+ * live containers of the `campaign` kind, and nothing else. A
67
68
  * request can claim any id, and a lead filed under another org's campaign
68
69
  * — or one the console soft-deleted — would sit on a page nobody in this
69
70
  * org can open. A lead is an org record, so any live campaign of the org
@@ -72,20 +73,14 @@ const NOTES_MAX = 4000;
72
73
  *
73
74
  * @returns the clean ids with their names, or `null` when one is not the org's.
74
75
  */ export async function orgCampaigns(firestore, orgId, raw) {
75
- const ids = normalizeCampaignIds(raw);
76
+ const ids = normalizeContainerIds(raw);
76
77
  if (!ids.length) return [];
77
78
  if (!orgId || ids.some((id)=>id.includes('/'))) return null;
78
- const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns');
79
- const found = await firestore.getAll(...ids.map((id)=>containers.doc(id)));
80
- const live = found.every((snapshot)=>snapshot.exists && !snapshot.get('deletedAt'));
81
- return live ? ids.map((id, index)=>{
82
- var _ref;
83
- var _found_index;
84
- return {
85
- id,
86
- name: String((_ref = (_found_index = found[index]) == null ? void 0 : _found_index.get('name')) != null ? _ref : '').trim() || id
87
- };
88
- }) : null;
79
+ const found = await readOrgContainers(firestore, 'campaign', orgId, ids);
80
+ return found.every((container)=>container.live) ? found.map((container)=>({
81
+ id: container.id,
82
+ name: container.name || container.id
83
+ })) : null;
89
84
  }
90
85
  /** The ids alone — see {@link orgCampaigns}. */ export async function orgCampaignIds(firestore, orgId, raw) {
91
86
  const campaigns = await orgCampaigns(firestore, orgId, raw);
@@ -297,7 +292,7 @@ export const leadCreateHandler = async (req, res)=>{
297
292
  * campaign the lead was not already in, by the member who added it.
298
293
  * After the write and never before, and never failing it — the
299
294
  * membership is on the document; the entry records the act.
300
- */ const already = readCampaignIds(before ? before.data() : null);
295
+ */ const already = readContainerIds(before ? before.data() : null, 'campaign');
301
296
  await fileCampaignFilingActivities(firestore, {
302
297
  orgId: resolved.orgId,
303
298
  org: resolved.org,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value\n * outside the list is refused under the field naming what the list\n * allows, a lead the site already held keeps the value it has, and a\n * new lead that named none starts from the list's default.\n */\n const leadSource = resolveLeadSourceWrite(\n await readLeadSourcePicklist(firestore, resolved.orgId),\n patch.leadSource,\n { current: before?.get('leadSource'), created },\n )\n if (leadSource.ok === false) {\n res.status(400).json({ error: leadSource.error, field: 'leadSource' })\n return\n }\n if (leadSource.write === undefined) delete patch.leadSource\n else patch.leadSource = leadSource.write\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n // The profile, tags and lead source are what the Leads list searches\n // and filters by (AGL-3321): restamped from the merged lead.\n await restampCrmListFieldsAt(leadsRef.doc(leadId), 'leads')\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","restampCrmListFieldsAt","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","readLeadSourcePicklist","resolveLeadSourceWrite","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","leadSource","current","ok","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GAED,SACEA,yBAAyB,EACzBC,0BAA0B,EAE1BC,eAAe,EAIfC,kBAAkB,EAClBC,qBAAqB,EACrBC,uBAAuB,EACvBC,SAAS,EAETC,kBAAkB,QACb,sBAAqB;AAC5B,SACEC,WAAW,EACXC,aAAa,EACbC,aAAa,EACbC,eAAe,EACfC,eAAe,EACfC,sBAAsB,EACtBC,eAAe,EACfC,YAAY,QACP,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,wCAAuC;AAC7E,SAASC,oBAAoB,EAAEC,eAAe,QAAQ,6CAA4C;AAClG,SAASC,UAAU,QAAQ,2BAA0B;AAErD,SAASC,4BAA4B,QAAQ,gCAA4B;AACzE,SAASC,sBAAsB,EAAEC,sBAAsB,QAAQ,4BAAwB;AACvF,SAASC,eAAe,QAAQ,kBAAc;AAC9C,SAASC,eAAe,QAAQ,kBAAc;AAE9C,gFAAgF,GAChF,MAAMC,kBAA4C;IAAC;IAAO;CAAU;AAEpE,MAAMC,WAAW;AACjB,MAAMC,YAAY;AAElB,iGAAiG,GACjG,OAAO,MAAMC,qBAAqB,SAAQ;AA2B1C,6FAA6F,GAC7F,OAAO,MAAMC,wBACX,qEAAoE;AAEtE;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,aACpBC,SAAsC,EACtCC,KAAa,EACbC,GAAY;IAEZ,MAAMC,MAAMjB,qBAAqBgB;IACjC,IAAI,CAACC,IAAIC,MAAM,EAAE,OAAO,EAAE;IAC1B,IAAI,CAACH,SAASE,IAAIE,IAAI,CAAC,CAACC,KAAOA,GAAGC,QAAQ,CAAC,OAAO,OAAO;IACzD,MAAMC,aAAaR,UAAUS,UAAU,CAAC,QAAQC,GAAG,CAACT,OAAOQ,UAAU,CAAC;IACtE,MAAME,QAAQ,MAAMX,UAAUY,MAAM,IAAIT,IAAIU,GAAG,CAAC,CAACP,KAAOE,WAAWE,GAAG,CAACJ;IACvE,MAAMQ,OAAOH,MAAMI,KAAK,CAAC,CAACC,WAAaA,SAASC,MAAM,IAAI,CAACD,SAASE,GAAG,CAAC;IACxE,OAAOJ,OACHX,IAAIU,GAAG,CAAC,CAACP,IAAIa;;YAA8BR;eAAnB;YAAEL;YAAIc,MAAMC,gBAAOV,eAAAA,KAAK,CAACQ,MAAM,qBAAZR,aAAcO,GAAG,CAAC,0BAAW,IAAII,IAAI,MAAMhB;QAAG;SACzF;AACN;AAEA,8CAA8C,GAC9C,OAAO,eAAeiB,eACpBvB,SAAsC,EACtCC,KAAa,EACbC,GAAY;IAEZ,MAAMsB,YAAY,MAAMzB,aAAaC,WAAWC,OAAOC;IACvD,OAAOsB,YAAYA,UAAUX,GAAG,CAAC,CAACY,WAAaA,SAASnB,EAAE,IAAI;AAChE;AASA,6EAA6E,GAC7E,OAAO,SAASoB,iBAAiBC,KAA0B;IACzD,MAAMC,QAAiC,CAAC;IACxC,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAACL,OAAQ;QAChD,IAAIG,UAAUG,WAAW;QACzBL,KAAK,CAACC,IAAI,GAAGC,UAAU,OAAO1C,WAAW8C,MAAM,KAAKJ;IACtD;IACA,OAAOF;AACT;AAEA,OAAO,MAAMO,oBAAsC,OAAOC,KAAKC;QAMhCD,4BAS4BA,WACnCE,cAuBGA,cAKLA,YAIIA,gBAGHA;IAlDrB,IAAIF,IAAIG,MAAM,KAAK,QAAQ;QACzBF,IAAIG,SAAS,CAAC,SAAS;QACvBH,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMC,gBAAgBvB,QAAOe,6BAAAA,IAAIS,OAAO,CAACD,aAAa,YAAzBR,6BAA6B;IAC1D,MAAMU,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcI,KAAK,CAAC,UAAU5C,MAAM,IACpC6B;IACJ,IAAI,CAACa,SAAS;QACZT,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkB;QAChD;IACF;IACA,MAAML,OACJ,OAAOF,IAAIE,IAAI,KAAK,WAAWW,KAAKC,KAAK,CAACd,IAAIE,IAAI,KAAKF,YAAAA,IAAIE,IAAI,YAARF,YAAY,CAAC;IACtE,MAAMe,SAAS9B,QAAOiB,eAAAA,KAAKa,MAAM,YAAXb,eAAe,IAClChB,IAAI,GACJ0B,KAAK,CAAC,GAAG;IACZ,IAAI,CAACG,QAAQ;QACXd,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiB;QAC/C;IACF;IACA,MAAMS,QAAQ/E,sBAAsBiE,KAAKc,KAAK;IAC9C,IAAI,CAACA,OAAO;QACVf,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;QAC7D;IACF;IACA;;;;GAIC,GACD,MAAM,EAAEhB,KAAK,EAAE0B,MAAM,EAAE,GAAG/E,wBAAwBgE;IAClD,MAAM,CAACgB,WAAW,GAAGvB,OAAOC,OAAO,CAACqB;IACpC,IAAIC,YAAY;QACdjB,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAOW,UAAU,CAAC,EAAE;YAAEC,OAAOD,UAAU,CAAC,EAAE;QAAC;QAClE;IACF;IACA,MAAME,YAAYnC,QAAOiB,eAAAA,KAAKG,MAAM,YAAXH,eAAe,IAAIhB,IAAI;IAChD,IAAIkC,aAAa,CAAC9D,gBAAgBa,QAAQ,CAACiD,YAA6B;QACtEnB,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAgC;QAC9D;IACF;IACA,MAAMvB,OAAOC,QAAOiB,aAAAA,KAAKlB,IAAI,YAATkB,aAAa,IAC9BhB,IAAI,GACJmC,OAAO,CAAC,QAAQ,KAChBT,KAAK,CAAC,GAAGrD;IACZ,MAAM+D,WAAWrC,QAAOiB,iBAAAA,KAAKoB,QAAQ,YAAbpB,iBAAiB,IACtChB,IAAI,GACJ0B,KAAK,CAAC,GAAG;IACZ,MAAMW,QAAQtC,QAAOiB,cAAAA,KAAKqB,KAAK,YAAVrB,cAAc,IAChChB,IAAI,GACJ0B,KAAK,CAAC,GAAGpD;IAEZ,IAAI;YAmKegE,MAAAA,eASYA;QA3K7B,MAAMA,UAAU,MAAMlF,cAAcmF,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACjB;QAC/D,MAAMkB,QAAQJ,OAAO,CAAC,QAAQ,KAAK;QACnC,MAAMK,aAAa,MAAMhF,sBAAsB2E,QAAQM,GAAG,EAAE;YAAEf;QAAO;QACrE,IACE,CAACa,SACD,CAAEC,CAAAA,WAAWE,QAAQ,IAAK,MAAM3E,gBAAgByE,WAAWhE,KAAK,EAAE2D,QAAQM,GAAG,CAAC,GAC9E;YACA7B,IACGI,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO;YAA0D;YAC3E;QACF;QACA,MAAMyB,WAAW,MAAMzF,cAAcwE;QACrC,IAAI,CAACiB,UAAU;YACb/B,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;YAC7C;QACF;QACA,MAAM0B,QAAQ5E,gBAAgB2E,SAASE,GAAG,EAAE;QAC5C,IAAID,OAAO;YACThC,IAAII,MAAM,CAAC4B,MAAM5B,MAAM,EAAEC,IAAI,CAAC2B,MAAM/B,IAAI;YACxC;QACF;QAEA,MAAMtC,YAAYtB,cAAcmF,GAAG,GAAG7D,SAAS;QAC/C,MAAMuE,UAAUvE,UAAUS,UAAU,CAAC,SAASC,GAAG,CAACyC;QAClD,MAAMqB,WAAW,MAAM3F,gBAAgBsE;QACvC;;;;;;KAMC,GACD,IAAIsB;QACJ,IAAInC,KAAKmC,MAAM,KAAKxC,WAAW;YAC7B,MAAMyC,cAAc,MAAM1E,UACvBS,UAAU,CAAC,QACXC,GAAG,CAAC0D,SAASnE,KAAK,EAClBQ,UAAU,CAACtC,gBAAgBwG,aAAa,EACxCC,KAAK,CAAC1G,4BACNgD,GAAG;YACN,MAAM2D,SAASrG,mBACb8D,KAAKmC,MAAM,EACXC,YAAYI,IAAI,CAACjE,GAAG,CAAC,CAACkE,QAAUA,MAAMC,IAAI,KAC1C;YAEF,IAAI,YAAYH,QAAQ;oBACG9C;gBAAzB,MAAM,CAACwB,OAAO0B,QAAQ,IAAGlD,mBAAAA,OAAOC,OAAO,CAAC6C,OAAOxB,MAAM,CAAC,CAAC,EAAE,YAAhCtB,mBAAoC,EAAE;gBAC/DM,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;oBACnBC,KAAK,EAAEsC,kBAAAA,UAAW;mBACd1B,QAAQ;oBAAEA;gBAAM,IAAI,CAAC;gBAE3B;YACF;YACAkB,SAASI,OAAOK,MAAM;QACxB;QACA,mEAAmE;QACnE,uCAAuC;QACvC,MAAM1D,YAAY,MAAMzB,aAAaC,WAAWoE,SAASnE,KAAK,EAAEqC,KAAK6C,WAAW;QAChF,IAAI3D,cAAc,MAAM;YACtBa,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO7C;gBAAuByD,OAAO;YAAc;YAC1E;QACF;QACA,MAAM4B,cAAc3D,UAAUX,GAAG,CAAC,CAACY,WAAaA,SAASnB,EAAE;QAC3D,sEAAsE;QACtE,4BAA4B;QAC5B,MAAM8E,SAAS7G,UAAU6E;QACzB,MAAMiC,SAAS,MAAMtG,gBAAgBoE,QAAQiC;QAC7C,MAAME,UAAU,CAACD;QACjB;;;;;KAKC,GACD,MAAME,aAAahG,uBACjB,MAAMD,uBAAuBU,WAAWoE,SAASnE,KAAK,GACtD0B,MAAM4D,UAAU,EAChB;YAAEC,OAAO,EAAEH,0BAAAA,OAAQnE,GAAG,CAAC;YAAeoE;QAAQ;QAEhD,IAAIC,WAAWE,EAAE,KAAK,OAAO;YAC3BpD,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO4C,WAAW5C,KAAK;gBAAEY,OAAO;YAAa;YACpE;QACF;QACA,IAAIgC,WAAW3D,KAAK,KAAKK,WAAW,OAAON,MAAM4D,UAAU;aACtD5D,MAAM4D,UAAU,GAAGA,WAAW3D,KAAK;QACxC;;;;KAIC,GACD,IAAI0D,SAAS;YACX,sEAAsE;YACtE,sEAAsE;YACtE,MAAMI,OAAO,AAAC,CAAA,MAAM1G,aAAawF,UAAUrB,QAAQwC,KAAK,GAAGzE,GAAG,EAAC,EAAG8D,IAAI,GAAGW,KAAK;YAC9E,IAAI1H,0BAA0ByH,MAAMtH,oBAAoBwH,QAAQ,EAAE;gBAChEvD,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;oBACnBC,OACE,kEACA,kEACA;oBACFkD,QAAQ;gBACV;gBACA;YACF;QACF;QACA,MAAMC,SAAS,MAAMrH,YAAY;YAC/B8F;YACApB;YACA4C,MAAM;gBACJ3C;eACIhC,OAAO;gBAAEA;YAAK,IAAI,CAAC;gBACvB4E,QAAQnG;;QAEZ;QACA,IAAI,CAACiG,QAAQ;YACXzD,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAA+B;YAC7D;QACF;QACA,MAAMsD,UAAmC,aACpCvE,iBAAiBC,QAChB6B,YAAY;YAAEf,QAAQe;QAAU,IAAI,CAAC,GACrCE,WAAW;YAAEA;QAAS,IAAI,CAAC,GAC3BC,QAAQ;YAAEA;QAAM,IAAI,CAAC,GAIrBwB,YAAY/E,MAAM,GAAG;YAAE+E,aAAa/F,WAAW8G,UAAU,IAAIf;QAAa,IAAI,CAAC,GAS/EV,UAAU1C,OAAOoE,IAAI,CAAC1B,QAAQrE,MAAM,GAAG;YAAEqE;QAAO,IAAI,CAAC;QAE3D,IAAI1C,OAAOoE,IAAI,CAACF,SAAS7F,MAAM,EAAE;YAC/B,MAAMoE,SACH9D,GAAG,CAAC0E,QACJgB,GAAG,CAAC,aAAKH;gBAASI,WAAWjH,WAAWkH,eAAe;gBAAM;gBAAEC,OAAO;YAAK;YAC9E,qEAAqE;YACrE,6DAA6D;YAC7D,MAAMzH,uBAAuB0F,SAAS9D,GAAG,CAAC0E,SAAS;QACrD;QACA;;;;;KAKC,GACD,MAAMoB,UAAUrH,gBAAgBkG,SAAUA,OAAOL,IAAI,KAAiC;QACtF,MAAM3F,6BAA6BW,WAAW;YAC5CC,OAAOmE,SAASnE,KAAK;YACrBqE,KAAKF,SAASE,GAAG;YACjBnB;YACAsD,MAAM;gBAAErB;YAAO;YACfsB,QAAQ;YACRlF,WAAWA,UAAUmF,MAAM,CAAC,CAAClF,WAAa,CAAC+E,QAAQjG,QAAQ,CAACkB,SAASnB,EAAE;YACvEsG,MAAMC,KAAKC,GAAG;YACdC,OAAOnD,QAAQM,GAAG;YAClB8C,QAAQ3F,QAAOuC,QAAAA,gBAAAA,OAAO,CAAC,OAAO,YAAfA,gBAAmBA,QAAQR,KAAK,YAAhCQ,OAAoC,IAAItC,IAAI,MAAM;QACnE;QACA;;;;KAIC,GACD,MAAM1C,gBACJuE,QACA;YAAEe,KAAKN,QAAQM,GAAG;YAAEd,KAAK,GAAEQ,iBAAAA,QAAQR,KAAK,YAAbQ,iBAAiB;QAAK,GACjD0B,UAAU,eAAe,gBACzB;YAAE2B,MAAM;YAAQ3G,IAAI8E;YAAQhE,MAAMA,QAAQgC;QAAM;QAElD,MAAM8D,SAA6B;YAAE9B;YAAQE;QAAQ;QACrDjD,IAAII,MAAM,CAAC6C,UAAU,MAAM,KAAK5C,IAAI,CAACwE;IACvC,EAAE,OAAOvE,OAAO;QACdwE,QAAQxE,KAAK,CAAC,uBAAuBA;QACrCN,IAAII,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;IAC/D;AACF,EAAC;AAED,eAAeR,kBAAiB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeContainerIds, readContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\nimport { readOrgContainers } from '@aglyn/tenant-data-admin/server/org-containers'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers of the `campaign` kind, and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeContainerIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const found = await readOrgContainers(firestore, 'campaign', orgId, ids)\n return found.every((container) => container.live)\n ? found.map((container) => ({ id: container.id, name: container.name || container.id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value\n * outside the list is refused under the field naming what the list\n * allows, a lead the site already held keeps the value it has, and a\n * new lead that named none starts from the list's default.\n */\n const leadSource = resolveLeadSourceWrite(\n await readLeadSourcePicklist(firestore, resolved.orgId),\n patch.leadSource,\n { current: before?.get('leadSource'), created },\n )\n if (leadSource.ok === false) {\n res.status(400).json({ error: leadSource.error, field: 'leadSource' })\n return\n }\n if (leadSource.write === undefined) delete patch.leadSource\n else patch.leadSource = leadSource.write\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n // The profile, tags and lead source are what the Leads list searches\n // and filters by (AGL-3321): restamped from the merged lead.\n await restampCrmListFieldsAt(leadsRef.doc(leadId), 'leads')\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readContainerIds(before ? (before.data() as Record<string, unknown>) : null, 'campaign')\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","restampCrmListFieldsAt","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeContainerIds","readContainerIds","readOrgContainers","FieldValue","fileCampaignFilingActivities","readLeadSourcePicklist","resolveLeadSourceWrite","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","found","every","container","live","map","name","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","JSON","parse","hostId","trim","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","collection","doc","leadsRef","custom","definitions","contactFields","limit","get","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","leadSource","current","ok","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type Firestore } from 'firebase-admin/firestore';
18
+ /** Move the delivered-to leads still New to Nurturing; how many moved. */
19
+ export declare function nurtureReachedLeads(firestore: Firestore, input: {
20
+ orgId: string;
21
+ hostId: string;
22
+ emails: readonly string[];
23
+ }): Promise<number>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHAT A DELIVERED MARKETING EMAIL DOES TO A LEAD (AGL-3446).
18
+ *
19
+ * A lead nobody has touched, once a campaign email has been delivered to
20
+ * it, moves from New to Nurturing: automated email is reaching it, so the
21
+ * CRM digest must not count it as unworked, but no person has engaged yet.
22
+ * Sequences make the same move from their own send job. A sender reaches
23
+ * this through the record email-state seam's `reached`
24
+ * (`stampRecordEmailReach`), never by importing the CRM.
25
+ *
26
+ * Found by address rather than by audience: a lead's document id IS the
27
+ * `personKey` of its address (`orgs/{orgId}/leads/{personKey}`, AGL-3275),
28
+ * so every audience — the leads, a list, a segment, typed-in addresses —
29
+ * resolves the people it reached to their lead rows by one batched read,
30
+ * with no query and no index.
31
+ *
32
+ * Only a lead the sending site holds is moved — `visibleTo` names it — so
33
+ * one brand's campaign never writes the stage of a lead another brand in
34
+ * the same organization keeps. Only `new` moves; Nurturing, Working, the
35
+ * closed states and a converted lead are left as they stand.
36
+ *
37
+ * Never throws: the mail has already gone, and a stage that could not be
38
+ * written is one a rep sets by hand.
39
+ */ import { crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
40
+ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
41
+ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
42
+ import { FieldValue } from "firebase-admin/firestore";
43
+ /** `getAll` takes this many references per call, and a batch this many writes. */ const CHUNK = 300;
44
+ /** Move the delivered-to leads still New to Nurturing; how many moved. */ export async function nurtureReachedLeads(firestore, input) {
45
+ if (!input.orgId || !input.emails.length) return 0;
46
+ const keys = [
47
+ ...new Set(input.emails.map((email)=>personKey(email)).filter((key)=>Boolean(key)))
48
+ ];
49
+ const leads = firestore.collection('orgs').doc(input.orgId).collection('leads');
50
+ let moved = 0;
51
+ for(let start = 0; start < keys.length; start += CHUNK){
52
+ try {
53
+ const snapshots = await firestore.getAll(...keys.slice(start, start + CHUNK).map((key)=>leads.doc(key)));
54
+ const batch = firestore.batch();
55
+ let writes = 0;
56
+ for (const snapshot of snapshots){
57
+ var _snapshot_data;
58
+ if (!snapshot.exists) continue;
59
+ const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
60
+ if (lead['convertedContactId']) continue;
61
+ if (!visibleToHost(lead['visibleTo'], input.hostId)) continue;
62
+ if (crmLeadStatus(lead) !== 'new') continue;
63
+ batch.set(snapshot.ref, {
64
+ status: 'nurturing',
65
+ updatedAt: FieldValue.serverTimestamp()
66
+ }, {
67
+ merge: true
68
+ });
69
+ writes += 1;
70
+ }
71
+ if (writes) {
72
+ await batch.commit();
73
+ moved += writes;
74
+ }
75
+ } catch (error) {
76
+ console.error('[crm] the reached leads could not be marked nurturing', input.orgId, error);
77
+ }
78
+ }
79
+ return moved;
80
+ }
81
+
82
+ //# sourceMappingURL=lead-nurturing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-nurturing.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A DELIVERED MARKETING EMAIL DOES TO A LEAD (AGL-3446).\n *\n * A lead nobody has touched, once a campaign email has been delivered to\n * it, moves from New to Nurturing: automated email is reaching it, so the\n * CRM digest must not count it as unworked, but no person has engaged yet.\n * Sequences make the same move from their own send job. A sender reaches\n * this through the record email-state seam's `reached`\n * (`stampRecordEmailReach`), never by importing the CRM.\n *\n * Found by address rather than by audience: a lead's document id IS the\n * `personKey` of its address (`orgs/{orgId}/leads/{personKey}`, AGL-3275),\n * so every audience — the leads, a list, a segment, typed-in addresses —\n * resolves the people it reached to their lead rows by one batched read,\n * with no query and no index.\n *\n * Only a lead the sending site holds is moved — `visibleTo` names it — so\n * one brand's campaign never writes the stage of a lead another brand in\n * the same organization keeps. Only `new` moves; Nurturing, Working, the\n * closed states and a converted lead are left as they stand.\n *\n * Never throws: the mail has already gone, and a stage that could not be\n * written is one a rep sets by hand.\n */\n\nimport { crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue, type Firestore } from 'firebase-admin/firestore'\n\n/** `getAll` takes this many references per call, and a batch this many writes. */\nconst CHUNK = 300\n\n/** Move the delivered-to leads still New to Nurturing; how many moved. */\nexport async function nurtureReachedLeads(\n firestore: Firestore,\n input: { orgId: string; hostId: string; emails: readonly string[] },\n): Promise<number> {\n if (!input.orgId || !input.emails.length) return 0\n const keys = [\n ...new Set(input.emails.map((email) => personKey(email)).filter((key): key is string => Boolean(key))),\n ]\n const leads = firestore.collection('orgs').doc(input.orgId).collection('leads')\n let moved = 0\n for (let start = 0; start < keys.length; start += CHUNK) {\n try {\n const snapshots = await firestore.getAll(...keys.slice(start, start + CHUNK).map((key) => leads.doc(key)))\n const batch = firestore.batch()\n let writes = 0\n for (const snapshot of snapshots) {\n if (!snapshot.exists) continue\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId']) continue\n if (!visibleToHost(lead['visibleTo'] as string[] | undefined, input.hostId)) continue\n if (crmLeadStatus(lead as never) !== 'new') continue\n batch.set(snapshot.ref, { status: 'nurturing', updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n writes += 1\n }\n if (writes) {\n await batch.commit()\n moved += writes\n }\n } catch (error) {\n console.error('[crm] the reached leads could not be marked nurturing', input.orgId, error)\n }\n }\n return moved\n}\n"],"names":["crmLeadStatus","personKey","visibleToHost","FieldValue","CHUNK","nurtureReachedLeads","firestore","input","orgId","emails","length","keys","Set","map","email","filter","key","Boolean","leads","collection","doc","moved","start","snapshots","getAll","slice","batch","writes","snapshot","exists","lead","data","hostId","set","ref","status","updatedAt","serverTimestamp","merge","commit","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,SAASA,aAAa,QAAQ,6BAA4B;AAC1D,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,UAAU,QAAwB,2BAA0B;AAErE,gFAAgF,GAChF,MAAMC,QAAQ;AAEd,wEAAwE,GACxE,OAAO,eAAeC,oBACpBC,SAAoB,EACpBC,KAAmE;IAEnE,IAAI,CAACA,MAAMC,KAAK,IAAI,CAACD,MAAME,MAAM,CAACC,MAAM,EAAE,OAAO;IACjD,MAAMC,OAAO;WACR,IAAIC,IAAIL,MAAME,MAAM,CAACI,GAAG,CAAC,CAACC,QAAUb,UAAUa,QAAQC,MAAM,CAAC,CAACC,MAAuBC,QAAQD;KACjG;IACD,MAAME,QAAQZ,UAAUa,UAAU,CAAC,QAAQC,GAAG,CAACb,MAAMC,KAAK,EAAEW,UAAU,CAAC;IACvE,IAAIE,QAAQ;IACZ,IAAK,IAAIC,QAAQ,GAAGA,QAAQX,KAAKD,MAAM,EAAEY,SAASlB,MAAO;QACvD,IAAI;YACF,MAAMmB,YAAY,MAAMjB,UAAUkB,MAAM,IAAIb,KAAKc,KAAK,CAACH,OAAOA,QAAQlB,OAAOS,GAAG,CAAC,CAACG,MAAQE,MAAME,GAAG,CAACJ;YACpG,MAAMU,QAAQpB,UAAUoB,KAAK;YAC7B,IAAIC,SAAS;YACb,KAAK,MAAMC,YAAYL,UAAW;oBAEnBK;gBADb,IAAI,CAACA,SAASC,MAAM,EAAE;gBACtB,MAAMC,QAAOF,iBAAAA,SAASG,IAAI,cAAbH,iBAAmB,CAAC;gBACjC,IAAIE,IAAI,CAAC,qBAAqB,EAAE;gBAChC,IAAI,CAAC5B,cAAc4B,IAAI,CAAC,YAAY,EAA0BvB,MAAMyB,MAAM,GAAG;gBAC7E,IAAIhC,cAAc8B,UAAmB,OAAO;gBAC5CJ,MAAMO,GAAG,CAACL,SAASM,GAAG,EAAE;oBAAEC,QAAQ;oBAAaC,WAAWjC,WAAWkC,eAAe;gBAAG,GAAG;oBAAEC,OAAO;gBAAK;gBACxGX,UAAU;YACZ;YACA,IAAIA,QAAQ;gBACV,MAAMD,MAAMa,MAAM;gBAClBlB,SAASM;YACX;QACF,EAAE,OAAOa,OAAO;YACdC,QAAQD,KAAK,CAAC,yDAAyDjC,MAAMC,KAAK,EAAEgC;QACtF;IACF;IACA,OAAOnB;AACT"}
@@ -77,6 +77,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
77
77
  import { LEAD_IMPORT_CHUNK_SIZE, LEAD_IMPORT_MAX_BODY_BYTES, normalizeLeadImportRow } from "../model/crm-lead-import.js";
78
78
  import { addHostLead, firebaseAdmin, orgLeadsForHost, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
79
79
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
80
+ import { listOrgContainers } from "@aglyn/tenant-data-admin/server/org-containers";
80
81
  // The leaf, for the reason the webhook imports the delivery log from its
81
82
  // leaf: a spec that stands a partial barrel in must still reach it.
82
83
  import { countUndeliverableEmails, findUndeliverableEmails, IMPORT_MAIL_CHECK_GRACE_MS, settleWithin } from "@aglyn/tenant-data-admin/server/capture-email-check";
@@ -100,8 +101,8 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
100
101
  */ const CAMPAIGN_DIRECTORY_CEILING = 200;
101
102
  /**
102
103
  * The org's live campaigns by NAME, lower-cased, for the `campaigns`
103
- * column. A lead is an org record, so every live container of the org
104
- * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is
104
+ * column. A lead is an org record, so every live container of the org's
105
+ * `campaign` kind may be named, whichever sites it is
105
106
  * placed on; where two share a name, the one placed on the importing site
106
107
  * wins, because that is the one its picker shows. A name the org does not
107
108
  * have is not guessed at: the row is refused whole and named, because a
@@ -110,15 +111,14 @@ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-pi
110
111
  */ async function campaignDirectory(firestore, orgId, hostId) {
111
112
  const directory = new Map();
112
113
  if (!orgId) return directory;
113
- const containers = await firestore.collection('orgs').doc(orgId).collection('emailCampaigns').limit(CAMPAIGN_DIRECTORY_CEILING).get();
114
- const live = containers.docs.filter((container)=>!container.get('deletedAt'));
115
- const placed = (container)=>visibleToHost(container.get('visibleTo'), hostId);
114
+ const containers = await listOrgContainers(firestore, 'campaign', orgId, CAMPAIGN_DIRECTORY_CEILING);
115
+ const live = containers.filter((container)=>container.live);
116
+ const placed = (container)=>visibleToHost(container.visibleTo, hostId);
116
117
  for (const container of [
117
118
  ...live.filter(placed),
118
119
  ...live.filter((entry)=>!placed(entry))
119
120
  ]){
120
- var _container_get;
121
- const name = String((_container_get = container.get('name')) != null ? _container_get : '').trim().toLowerCase();
121
+ const name = container.name.toLowerCase();
122
122
  if (name && !directory.has(name)) directory.set(name, container.id);
123
123
  }
124
124
  return directory;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport {\n addHostLead,\n firebaseAdmin,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\n// The leaf, for the reason the webhook imports the delivery log from its\n// leaf: a spec that stands a partial barrel in must still reach it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n /*\n * THE ADDRESSES' MAIL SERVERS (AGL-3328), asked now so the answers\n * arrive while the rows are written, and read after the last one for\n * the result's \"N addresses have no mail server\". The records\n * themselves are stamped by the check `addHostLead` queues.\n */\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n // The file's profile, tags, status and lead source are what the\n // Leads list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(leadsRef.doc(key), 'leads')\n }\n imported.push(row.email)\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","restampCrmListFieldsAt","visibleToHost","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","mailServers","map","imported","app","hostRef","leadsRef","refs","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","leadSource","current","write","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","noMailServer","result","received","sort","a","b","undefined","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GAED,SACEA,yBAAyB,EAEzBC,kBAAkB,EAClBC,SAAS,QAEJ,sBAAqB;AAC5B,SACEC,sBAAsB,EACtBC,0BAA0B,EAK1BC,sBAAsB,QACjB,8BAA0B;AACjC,SACEC,WAAW,EACXC,aAAa,EACbC,eAAe,EACfC,sBAAsB,QACjB,2BAA0B;AACjC,SAASC,aAAa,QAAQ,sCAAqC;AACnE,yEAAyE;AACzE,oEAAoE;AACpE,SACEC,wBAAwB,EACxBC,uBAAuB,EACvBC,0BAA0B,EAC1BC,YAAY,QACP,sDAAqD;AAC5D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,cAAc,EACdC,cAAc,EACdC,oBAAoB,QACf,sBAAkB;AACzB,SAASC,sBAAsB,EAAEC,sBAAsB,QAAQ,4BAAwB;AAEvF;;;;;;;;;CASC,GACD,MAAMC,qBAAoC;AAE1C;;;;CAIC,GACD,MAAMC,6BAA6B;AAEnC;;;;;;;;;CASC,GACD,eAAeC,kBACbC,SAAsC,EACtCC,KAAa,EACbC,MAAc;IAEd,MAAMC,YAAY,IAAIC;IACtB,IAAI,CAACH,OAAO,OAAOE;IACnB,MAAME,aAAa,MAAML,UACtBM,UAAU,CAAC,QACXC,GAAG,CAACN,OACJK,UAAU,CAAC,kBACXE,KAAK,CAACV,4BACNW,GAAG;IACN,MAAMC,OAAOL,WAAWM,IAAI,CAACC,MAAM,CAAC,CAACC,YAAc,CAACA,UAAUJ,GAAG,CAAC;IAClE,MAAMK,SAAS,CAACD,YACd3B,cAAc2B,UAAUJ,GAAG,CAAC,cAAsCP;IACpE,KAAK,MAAMW,aAAa;WAAIH,KAAKE,MAAM,CAACE;WAAYJ,KAAKE,MAAM,CAAC,CAACG,QAAU,CAACD,OAAOC;KAAQ,CAAE;YACvEF;QAApB,MAAMG,OAAOC,QAAOJ,iBAAAA,UAAUJ,GAAG,CAAC,mBAAdI,iBAAyB,IAAIK,IAAI,GAAGC,WAAW;QACnE,IAAIH,QAAQ,CAACb,UAAUiB,GAAG,CAACJ,OAAOb,UAAUkB,GAAG,CAACL,MAAMH,UAAUS,EAAE;IACpE;IACA,OAAOnB;AACT;AAEA;;;;;CAKC,GACD,SAASoB,aACPC,GAAkB,EAClBC,QAA4B,EAC5BC,WAA8B;IAE9B,MAAMC,SAAkC,aAClCH,IAAII,MAAM,GAAG;QAAEA,QAAQJ,IAAII,MAAM;IAAC,IAAI,CAAC,GACvCH,WAAW;QAAEA;IAAS,IAAI,CAAC,GAC3BD,IAAIK,iBAAiB,GAAG;QAAEA,mBAAmBL,IAAIK,iBAAiB;IAAC,IAAI,CAAC,GACxEL,IAAIM,KAAK,GAAG;QAAEA,OAAON,IAAIM,KAAK;IAAC,IAAI,CAAC,GACrCN,IAAIO,OAAO,EAIVL,YAAYM,MAAM,GAAG;QAAEN,aAAanC,WAAW0C,UAAU,IAAIP;IAAa,IAAI,CAAC;IAErF,OAAOQ,OAAOC,IAAI,CAACR,QAAQK,MAAM,GAAGL,SAAS;AAC/C;AAEA;;;;;CAKC,GACD,OAAO,MAAMS,wBAA0C,OAAOC,KAAKC;IACjE,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvB,OAAOF,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,OAAOlD,eAAiC4C,KAAK;QACjDO,cAAchE;QACdiE,WAAWlE;IACb;IACA,IAAI,WAAWgE,MAAM,OAAOL,IAAIV,MAAM,CAACe,KAAKf,MAAM,EAAEa,IAAI,CAAC;QAAEC,OAAOC,KAAKD,KAAK;IAAC;IAC7E,IAAI;QACF,MAAMI,UAAU,MAAMpD,qBAAqB2C;QAC3C,IAAIS,QAAQC,EAAE,KAAK,OAAO,OAAOT,IAAIV,MAAM,CAACkB,QAAQlB,MAAM,EAAEa,IAAI,CAACK,QAAQE,IAAI;QAE7E,MAAMC,UAAkC,EAAE;QAC1C,MAAMC,UAAkC,CAAC;QACzC,MAAMC,aAAmE,EAAE;QAC3E;;;;;;KAMC,GACD,MAAMC,OAAO,IAAIC;QACjBV,KAAKW,IAAI,CAACC,OAAO,CAAC,CAACC,KAAKC;YACtB,MAAMC,UAAU7E,uBAAuB2E;YACvC,IAAIE,QAAQX,EAAE,KAAK,OAAO;gBACxBE,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQG,KAAK;oBAAEC,QAAQJ,QAAQI,MAAM;gBAAC;gBACnE;YACF;YACA,iEAAiE;YACjE,iCAAiC;YACjC,MAAMC,MAAMrF,UAAUgF,QAAQlC,GAAG,CAACoC,KAAK;YACvC,IAAIR,KAAKhC,GAAG,CAAC2C,MAAM;gBACjBd,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQlC,GAAG,CAACoC,KAAK;oBAAEE,QAAQ;gBAAY;gBACpE;YACF;YACAV,KAAKY,GAAG,CAACD;YACT,KAAK,MAAMhD,SAAS2C,QAAQlC,GAAG,CAAC0B,OAAO,CAAE;oBACfA;gBAAxBA,OAAO,CAACnC,MAAMkD,KAAK,CAAC,GAAG,EAACf,uBAAAA,OAAO,CAACnC,MAAMkD,KAAK,CAAC,YAApBf,uBAAwB,KAAK;YACvD;YACAC,WAAWQ,IAAI,CAAC;gBAAEF;gBAAOjC,KAAKkC,QAAQlC,GAAG;gBAAEuC;YAAI;QACjD;QAEA;;;;;KAKC,GACD,MAAMG,cAAc9E,wBAAwB+D,WAAWgB,GAAG,CAAC,CAACpD,QAAUA,MAAMS,GAAG,CAACoC,KAAK;QACrF,MAAMQ,WAAqB,EAAE;QAE7B,MAAMpE,YAAYjB,cAAcsF,GAAG,GAAGrE,SAAS;QAC/C,MAAMsE,UAAUtE,UAAUM,UAAU,CAAC,SAASC,GAAG,CAACuC,QAAQ5C,MAAM;QAChE,MAAMqE,WAAW,MAAMvF,gBAAgB8D,QAAQ5C,MAAM;QACrD,MAAMsE,OAAOrB,WAAWgB,GAAG,CAAC,CAACpD,QAAUwD,SAAShE,GAAG,CAACQ,MAAMgD,GAAG;QAC7D,MAAMU,iBAAiBtB,WAAWuB,IAAI,CAAC,CAAC3D;gBAAUA;oBAAAA,uBAAAA,MAAMS,GAAG,CAACmD,SAAS,qBAAnB5D,qBAAqBiB,MAAM;;QAC7E,MAAM,CAAC4C,QAAQC,QAAQF,WAAWG,YAAY,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACjExF,eACEsD,QAAQ7C,KAAK,EACbkD,WAAWgB,GAAG,CAAC,CAACpD,QAAUA,MAAMS,GAAG;YAErCgD,KAAKxC,MAAM,GAAGhC,UAAUiF,MAAM,IAAIT,QAAQO,QAAQG,OAAO,CAAC,EAAE;YAC5DT,iBACI1E,kBAAkBC,WAAW8C,QAAQ7C,KAAK,EAAE6C,QAAQ5C,MAAM,IAC1D6E,QAAQG,OAAO,CAAC,IAAI9E;YACxB,qEAAqE;YACrE,2DAA2D;YAC3DT,uBAAuBK,WAAW8C,QAAQ7C,KAAK;SAChD;QACD,MAAMkF,aAAa,IAAI/E,IAAIyE,OAAOV,GAAG,CAAC,CAACiB,WAAa;gBAACA,SAAS9D,EAAE;gBAAE8D;aAAS;QAC3E,MAAMC,OAAO,IAAIhC,IACfwB,OAAOjE,MAAM,CAAC,CAACwE,WAAaA,SAASE,MAAM,EAAEnB,GAAG,CAAC,CAACiB,WAAaA,SAAS9D,EAAE;QAE5E,MAAMiE,mBAAmB,IAAIlC;QAC7B,IAAImC,UAAyB;QAC7B,IAAIC,cAAc;QAClB,IAAIC,UAAU;QACd,IAAIC,SAAS;QAEb,KAAK,MAAM,EAAElC,KAAK,EAAEjC,GAAG,EAAEuC,GAAG,EAAE,IAAIZ,WAAY;gBAGvB3B;gBAeV2D;YAjBX,mEAAmE;YACnE,6CAA6C;YAC7C,MAAMzD,cAAc,EAACF,iBAAAA,IAAImD,SAAS,YAAbnD,iBAAiB,EAAE,EAAE2C,GAAG,CAAC,CAACnD;oBAAS2D;wBAAAA,iBAAAA,UAAUlE,GAAG,CAACO,KAAKG,WAAW,eAA9BwD,iBAAqC;;YAC7F,IAAIjD,YAAYgD,IAAI,CAAC,CAACpD,KAAO,CAACA,KAAK;gBACjC2B,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAmB;gBACnE;YACF;YACA,MAAM8B,QAAQ,CAACP,KAAKjE,GAAG,CAAC2C;YACxB;;;;;;;OAOC,GACD,MAAM8B,aAAajG,uBAAuBkF,aAAatD,IAAIO,OAAO,CAAC8D,UAAU,EAAE;gBAC7EC,OAAO,GAAEX,kBAAAA,WAAW1E,GAAG,CAACsD,yBAAfoB,gBAAqB1E,GAAG,CAAC;gBAClCiF,SAASE;YACX;YACA,IAAIC,WAAW9C,EAAE,KAAK,OAAO;gBAC3BE,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAsB;gBACtE;YACF;YACA,IAAI+B,WAAWE,KAAK,EAAEvE,IAAIO,OAAO,CAAC8D,UAAU,GAAGA,WAAWE,KAAK;YAC/D;;;;;;;OAOC,GACD,IAAIH,OAAO;gBACT,IAAIJ,YAAY,MAAM;oBACpBA,UAAU,AAAC,CAAA,MAAMjB,SAASyB,KAAK,GAAGvF,GAAG,EAAC,EAAGwF,IAAI,GAAGD,KAAK;gBACvD;gBACA,IACExH,0BAA0BgH,UAAUC,aAAahH,oBAC9CyH,QAAQ,EACX;oBACAjD,QAAQU,IAAI,CAAC;wBAAEF;wBAAOG,OAAOpC,IAAIoC,KAAK;wBAAEE,QAAQ;oBAAe;oBAC/D;gBACF;YACF;YACA,IAAIrC;YACJ,IAAID,IAAI2E,UAAU,EAAE;gBAClB1E,WAAWmD,OAAOnE,GAAG,CAACe,IAAI2E,UAAU;gBACpC,IAAI,CAAC1E,UAAU8D,iBAAiBvB,GAAG,CAACxC,IAAI2E,UAAU;YACpD;YACA,MAAMC,SAAS,MAAMtH,YAAY;gBAC/BwF;gBACApE,QAAQ4C,QAAQ5C,MAAM;gBACtBmG,MAAM;oBACJzC,OAAOpC,IAAIoC,KAAK;mBACZpC,IAAIR,IAAI,GAAG;oBAAEA,MAAMQ,IAAIR,IAAI;gBAAC,IAAI,CAAC;oBACrCsF,QAAQzG;;YAEZ;YACA,IAAI,CAACuG,QAAQ;gBACXnD,QAAQU,IAAI,CAAC;oBAAEF;oBAAOG,OAAOpC,IAAIoC,KAAK;oBAAEE,QAAQ;gBAAe;gBAC/D;YACF;YACA,MAAMyC,UAAUhF,aAAaC,KAAKC,UAAU;mBAAI,IAAI4B,IAAI3B;aAAa;YACrE,IAAI6E,SAAS;gBACX,MAAMhC,SACHhE,GAAG,CAACwD,KACJ1C,GAAG,CACF,aAAKkF;oBAASC,WAAWjH,WAAWkH,eAAe;oBACnD;oBAAEC,OAAO;gBAAK;gBAElB,gEAAgE;gBAChE,iDAAiD;gBACjD,MAAMzH,uBAAuBsF,SAAShE,GAAG,CAACwD,MAAM;YAClD;YACAK,SAAST,IAAI,CAACnC,IAAIoC,KAAK;YACvB,IAAIgC,OAAO;gBACTF,WAAW;gBACXD,eAAe;YACjB,OAAO;gBACLE,UAAU;YACZ;QACF;QACA,MAAMgB,eAAexH,yBACnBiF,UACA,MAAM9E,aAAa4E,aAAa7E;QAGlC,MAAMuH,SAAgC;YACpCC,UAAUlE,KAAKW,IAAI,CAACtB,MAAM;YAC1B0D;YACAC;YACA1C,SAASA,QAAQ6D,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEtD,KAAK,GAAGuD,EAAEvD,KAAK;YACjDP;YACAqC,kBAAkB;mBAAIA;aAAiB;WACnCoB,iBAAiBM,YAAY;YAAEN;QAAa,IAAI,CAAC;QAEvD,OAAOrE,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAACmE;IAC9B,EAAE,OAAOlE,OAAO;QACdwE,QAAQxE,KAAK,CAAC,2BAA2BA;QACzC,OAAOJ,IAAIV,MAAM,CAAC,KAAKa,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACxE;AACF,EAAC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport {\n addHostLead,\n firebaseAdmin,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n listOrgContainers,\n type OrgContainerRecord,\n} from '@aglyn/tenant-data-admin/server/org-containers'\n// The leaf, for the reason the webhook imports the delivery log from its\n// leaf: a spec that stands a partial barrel in must still reach it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org's\n * `campaign` kind may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await listOrgContainers(firestore, 'campaign', orgId, CAMPAIGN_DIRECTORY_CEILING)\n const live = containers.filter((container) => container.live)\n const placed = (container: OrgContainerRecord) => visibleToHost(container.visibleTo, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = container.name.toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n /*\n * THE ADDRESSES' MAIL SERVERS (AGL-3328), asked now so the answers\n * arrive while the rows are written, and read after the last one for\n * the result's \"N addresses have no mail server\". The records\n * themselves are stamped by the check `addHostLead` queues.\n */\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n // The file's profile, tags, status and lead source are what the\n // Leads list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(leadsRef.doc(key), 'leads')\n }\n imported.push(row.email)\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","restampCrmListFieldsAt","visibleToHost","listOrgContainers","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","live","filter","container","placed","visibleTo","entry","name","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","mailServers","map","imported","app","hostRef","collection","doc","leadsRef","refs","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","get","isNew","leadSource","current","write","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","noMailServer","result","received","sort","a","b","undefined","console"],"mappings":"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"}