@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
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/export-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_COLLECTIONS,\n csvCell,\n hostScopeToken,\n isOrgWideMember,\n MAX_SCOPE_HOSTS,\n memberCanSee,\n memberScopeTokens,\n scopeTokensForHost,\n planLabelGrantingFeature,\n pluginRequestFromWeb,\n} from '@aglyn/aglyn/server'\nimport {\n emailUnverifiedResponse,\n firebaseAdmin,\n isImpersonationSession,\n isServerReleaseFlagOnForOrg,\n lockdownRefusal,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\nimport { invalidIdTokenResponse } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n contactCsvRowFromDoc,\n crmExportCells,\n crmExportHeader,\n type CrmExportOptions,\n type CrmExportResource,\n isCrmExportResource,\n} from '../model/crm-csv'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/**\n * Documents read per round trip. Not a cap — the stream keeps paging until\n * the collection is exhausted. It bounds what is held at once, which is the\n * only thing a page size should ever bound.\n */\nconst PAGE_SIZE = 500\n\n/** Sites read per page when the leads file spans an organization. */\nconst HOST_PAGE = 100\n\n/**\n * The most sites one organization-level leads file reads from.\n *\n * A ceiling rather than an unbounded sweep, and it is REPORTED: the file's\n * row header counts only what was read, so a short file is provably short\n * rather than quietly partial.\n */\nconst HOST_CEILING = 200\n\n/** The most linked records a tasks file resolves names for before writing ids. */\nconst NAME_CACHE_CEILING = 2000\n\n/** Header naming the row count the server undertook to send. */\nexport const EXPORT_ROWS_HEADER = 'X-Aglyn-Export-Rows'\n\nconst json = (body: unknown, status: number) => Response.json(body, { status })\n\n/** Which collection under the org root each resource is, bar leads. */\nconst ORG_COLLECTION: Record<Exclude<CrmExportResource, 'leads'>, string> = {\n contacts: 'contacts',\n companies: CRM_COLLECTIONS.companies,\n deals: CRM_COLLECTIONS.deals,\n tasks: CRM_COLLECTIONS.tasks,\n}\n\n/** The file's name, before the date stamp. */\nconst FILE_BASE: Record<CrmExportResource, string> = {\n contacts: 'contacts',\n companies: 'companies',\n deals: 'deals',\n tasks: 'tasks',\n leads: 'leads',\n}\n\n/**\n * THE WHOLE COLLECTION, STREAMED (AGL-2662).\n *\n * ## What this fixes\n *\n * Every CRM export until now wrote the rows the LIST had loaded — a\n * `limit(1000)` window for contacts, a `limit(200)` view for tasks — and\n * called the file `contacts.csv`. A merchant with 40,000 contacts got 1,000\n * of them and nothing on screen or in the file said so. `docs/bulk-actions.md`\n * said it in prose, which is not where somebody opening a spreadsheet looks.\n * This is the same file over every row there is, and it carries\n * `X-Aglyn-Export-Rows` so the client can prove what it received is whole —\n * a stream that dies halfway yields a perfectly well-formed shorter file, and\n * nothing about the bytes says they are short.\n *\n * ## Why it is a route and not a bigger client read\n *\n * A browser cannot hold an unbounded collection, and the tier that most\n * needs a complete export is the tier with the most rows. Cursor-paged and\n * streamed, completeness costs neither a hang nor a buffer.\n *\n * ## The columns are the client's columns\n *\n * `crmExportHeader` and `crmExportCells` (`model/crm-csv.ts`) are the\n * writers each section's Export button uses. One feature, one file format.\n *\n * ## The order is by document id, deliberately\n *\n * Firestore DROPS every document missing the ordered field, so ordering by\n * `updatedAt` or `dueAtMs` would silently omit rows written before that\n * field existed — the exact silent loss an export must not have. A document\n * id is on every document and is a total order.\n *\n * ## Scope is enforced here, because nothing else can\n *\n * The Admin SDK bypasses the rules, so `memberScopeTokens` IS the\n * enforcement: an org-wide member reads every row, and a scoped\n * collaborator's query carries their own tokens as the `array-contains-any`\n * the rules would have evaluated. Leads are host data by path, so the\n * equivalent check is whether the caller reaches the site.\n */\nexport async function crmExportRoute(request: Request): Promise<Response> {\n const { method, query, headers: rawHeaders } =\n await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'GET') return json({ error: 'Method not allowed' }, 405)\n\n const authorization = headers.authorization ?? ''\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return json({ error: 'Unauthenticated' }, 401)\n\n const orgId = String(query?.['orgId'] ?? '')\n const resource = String(query?.['resource'] ?? '')\n const hostId = String(query?.['hostId'] ?? '')\n if (!orgId || !isCrmExportResource(resource)) {\n return json({ error: 'Missing orgId or resource' }, 400)\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!decoded.email_verified && !isImpersonationSession(decoded)) {\n return emailUnverifiedResponse()\n }\n const staff = decoded['staff'] === true\n const membership = await resolveOrgMembership(decoded.uid, orgId)\n const member = membership?.member\n if (!member && !staff) return json({ error: 'Not found' }, 404)\n\n /*\n * THE PEOPLE FILES ARE EVERY WORKSPACE'S (AGL-2839, AGL-2851).\n *\n * Exporting the contacts and leads a workspace holds is an obligation,\n * not a CRM feature — a Free workspace takes them from its Privacy\n * settings — so neither the release flag nor the plan is asked of those\n * two. The CRM's own records, companies, deals and tasks, are asked\n * both: the flag here, and the plan once the org has been read.\n */\n const peopleFile = resource === 'contacts' || resource === 'leads'\n if (!peopleFile && !staff && !(await isServerReleaseFlagOnForOrg('release_crm', orgId))) {\n return json({ error: 'Not available' }, 404)\n }\n // The permission the CRM's own rules read for. Reading is not writing,\n // but these records ARE the audience: `data.manage` is what admits a\n // person to the list this file copies.\n if (!staff && !(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return json({ error: 'Not found' }, 404)\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) return json({ error: 'Unknown organization' }, 404)\n const org = orgSnapshot.data()\n\n const locked = await lockdownRefusal({\n request,\n staff,\n uid: decoded.uid,\n org,\n })\n if (locked) return locked\n\n // The CRM's own records on a plan without the CRM, staff included: the\n // refusal every `crm/*` route gives, after the caller is known.\n if (!peopleFile && !checkEntitlement(org as never, 'crm')) {\n const plan = planLabelGrantingFeature('crm')\n return json(\n {\n error:\n `Exporting ${resource} is part of the CRM, which is not included in ` +\n 'your current plan. Manage your plan and add-ons from Billing.' +\n (plan ? ` Included from ${plan}.` : ''),\n reason: 'plan_required',\n code: 'crm',\n },\n 403,\n )\n }\n\n const orgWide = staff || isOrgWideMember(member)\n // `array-contains-any` takes at most thirty values, which is also the\n // widest a consent group can be — a reader past that is the org-wide\n // case and never reaches here.\n const tokens = memberScopeTokens(member).slice(0, MAX_SCOPE_HOSTS)\n if (!orgWide && !tokens.length) return json({ error: 'Not found' }, 404)\n\n /* The roster, once, so every owner and assignee cell is an address. */\n const ownerEmails = new Map<string, string>()\n for (const entry of (await orgRef.collection('members').get()).docs) {\n const email = String(entry.data()?.['email'] ?? '')\n if (email) ownerEmails.set(entry.id, email)\n }\n const ownerEmail = (uid: string) => ownerEmails.get(uid) ?? uid\n\n const options: CrmExportOptions = { ownerEmail, assigneeEmail: ownerEmail }\n\n if (resource === 'contacts') {\n /*\n * A contact's fields live in one holder's FACET, so the file has to\n * say which. Under a site that is the site's own group, for every\n * row; at the organization level it is each row's PRIMARY holder —\n * the same answer the org-level Contacts list flattens by, so the\n * file and the table read one profile per person. What a file must\n * never do is read the top of the document, which is where the\n * pre-facet migration left one holder's fields.\n */\n if (!hostId && !orgWide) return json({ error: 'Missing hostId' }, 400)\n const fields = await orgRef.collection(CRM_COLLECTIONS.contactFields).get()\n options.customFields = fields.docs\n .map((entry) => ({\n key: String(entry.data()?.['key'] ?? entry.id),\n label: String(entry.data()?.['label'] ?? entry.id),\n archivedAt: entry.data()?.['archivedAt'],\n }))\n .filter((field) => !field.archivedAt)\n .map(({ key, label }) => ({ key, label }))\n }\n\n if (resource === 'deals') {\n const pipelines = await orgRef.collection(CRM_COLLECTIONS.pipelines).get()\n const names = new Map<string, string>()\n const stages = new Map<string, string>()\n for (const entry of pipelines.docs) {\n const data = entry.data() ?? {}\n names.set(entry.id, String(data['name'] ?? entry.id))\n for (const stage of (data['stages'] ?? []) as Array<Record<string, unknown>>) {\n const stageId = String(stage?.['id'] ?? '')\n if (stageId) {\n stages.set(`${entry.id}/${stageId}`, String(stage?.['name'] ?? stageId))\n }\n }\n }\n options.pipelineName = (id) => names.get(id)\n options.stageName = (pipelineId, stageId) =>\n stages.get(`${pipelineId}/${stageId}`)\n }\n\n /*\n * A task names its contact, company and deal by ID, and a spreadsheet\n * cannot read one. The list resolves them from rows it already holds;\n * the server looks them up as it meets them — batched per page,\n * remembered, and capped, so a tasks file costs reads in proportion to\n * the records it actually references rather than to the org. Past the\n * cap the id is written, which is the documented degradation everywhere\n * else in these files.\n */\n const recordNames = new Map<string, string>()\n const resolveNames = async (\n wanted: ReadonlyArray<{ kind: 'contact' | 'company' | 'deal'; id: string }>,\n ) => {\n const missing = wanted.filter(\n (entry) =>\n entry.id &&\n !recordNames.has(`${entry.kind}:${entry.id}`) &&\n recordNames.size < NAME_CACHE_CEILING,\n )\n if (!missing.length) return\n const refs = missing.map((entry) =>\n orgRef\n .collection(\n entry.kind === 'contact'\n ? 'contacts'\n : entry.kind === 'company'\n ? CRM_COLLECTIONS.companies\n : CRM_COLLECTIONS.deals,\n )\n .doc(entry.id),\n )\n const snapshots = await firestore.getAll(...refs)\n snapshots.forEach((snapshot, index) => {\n const entry = missing[index]\n const data = snapshot.data() ?? {}\n const name =\n entry.kind === 'deal'\n ? String(data['title'] ?? '')\n : String(data['name'] ?? data['email'] ?? '')\n recordNames.set(`${entry.kind}:${entry.id}`, name || entry.id)\n })\n }\n if (resource === 'tasks') {\n options.recordName = (kind, id) => recordNames.get(`${kind}:${id}`)\n }\n\n /*\n * LEADS ARE HOST DATA BY PATH. Under one site the file is that site's;\n * with no site named, an org-wide member takes every site's leads in one\n * file with a `Site` column — the same file the organization-level Leads\n * list writes. A scoped collaborator must name a site they reach.\n */\n const siteNames = new Map<string, string>()\n let leadHostIds: string[] = []\n if (resource === 'leads') {\n if (hostId) {\n if (!orgWide && !memberCanSee(member, [hostScopeToken(hostId)])) {\n return json({ error: 'Not found' }, 404)\n }\n leadHostIds = [hostId]\n } else {\n if (!orgWide) return json({ error: 'Missing hostId' }, 400)\n let cursor: string | null = null\n for (;;) {\n let page = firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .orderBy(FieldPath.documentId())\n .limit(HOST_PAGE)\n if (cursor) page = page.startAfter(firestore.collection('hosts').doc(cursor))\n const snapshot = await page.get()\n for (const entry of snapshot.docs) {\n leadHostIds.push(entry.id)\n siteNames.set(\n entry.id,\n String(\n entry.data()?.['displayName'] ??\n entry.data()?.['subdomain'] ??\n entry.id,\n ),\n )\n }\n if (snapshot.docs.length < HOST_PAGE) break\n if (leadHostIds.length >= HOST_CEILING) break\n cursor = snapshot.docs[snapshot.docs.length - 1].id\n }\n options.siteName = (id) => siteNames.get(id)\n }\n }\n\n /** One resource's rows, as a query per collection the file spans. */\n const sources =\n resource === 'leads'\n ? leadHostIds.map((id) => ({\n hostId: id,\n /*\n * The org collection, narrowed per SITE (AGL-3275).\n *\n * One source per site is still right — the file names the site a\n * row came from — but a site's leads are selected by `visibleTo`\n * now rather than by the collection's parent, so the narrowing\n * moves onto the reference itself. It cannot use the `scoped`\n * flag below: that applies the MEMBER's tokens, and here each\n * source needs the tokens of its own site.\n */\n reference: orgRef\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(id)),\n scoped: false,\n }))\n : [\n {\n hostId: '',\n reference: orgRef.collection(\n ORG_COLLECTION[resource as Exclude<CrmExportResource, 'leads'>],\n ),\n scoped: !orgWide,\n },\n ]\n\n // Taken BEFORE the first page, so it describes the collections the\n // export started from. A `count()` aggregate bills one read per 1,000\n // documents — cheap next to the export it is describing, and the only\n // way the client can tell a complete file from a truncated one.\n let total = 0\n for (const source of sources) {\n const counted = source.scoped\n ? source.reference.where('visibleTo', 'array-contains-any', tokens)\n : source.reference\n total += Number((await counted.count().get()).data().count ?? 0)\n }\n\n /** The holder one contact is written through, resolved per level. */\n const holderFor = (document: Record<string, unknown>) => {\n const group = hostId\n ? consentGroupForHost(org as Record<string, unknown>, hostId)\n : contactPrimaryGroup(document, org as Record<string, unknown>)\n return { groupId: group.groupId, hostIds: group.hostIds }\n }\n\n const encoder = new TextEncoder()\n const encode = (text: string) => encoder.encode(text)\n const line = (cells: readonly unknown[]) => cells.map(csvCell).join(',')\n let sourceIndex = 0\n let cursor: FirebaseFirestore.QueryDocumentSnapshot | null = null\n let phase: 'head' | 'body' | 'tail' = 'head'\n\n const stream = new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n if (phase === 'head') {\n phase = sources.length ? 'body' : 'tail'\n controller.enqueue(encode(line(crmExportHeader(resource, options))))\n return\n }\n if (phase === 'body') {\n const source = sources[sourceIndex]\n let page: FirebaseFirestore.Query = source.reference\n if (source.scoped) {\n page = page.where('visibleTo', 'array-contains-any', tokens)\n }\n page = page.orderBy(FieldPath.documentId()).limit(PAGE_SIZE)\n if (cursor) page = page.startAfter(cursor)\n const snapshot = await page.get()\n const exhausted = snapshot.docs.length < PAGE_SIZE\n cursor = exhausted\n ? null\n : (snapshot.docs[snapshot.docs.length - 1] ?? null)\n if (exhausted) {\n sourceIndex += 1\n if (sourceIndex >= sources.length) phase = 'tail'\n }\n if (!snapshot.empty) {\n const documents = snapshot.docs.map((entry) => ({\n ...(entry.data() ?? {}),\n $id: entry.id,\n ...(source.hostId ? { hostId: source.hostId } : {}),\n }))\n if (resource === 'tasks') {\n await resolveNames(\n documents.flatMap((document) =>\n (['contact', 'company', 'deal'] as const).map((kind) => ({\n kind,\n id: String(\n document[\n kind === 'contact'\n ? 'contactId'\n : kind === 'company'\n ? 'companyId'\n : 'dealId'\n ] ?? '',\n ),\n })),\n ),\n )\n }\n const lines = documents.map((document) =>\n line(\n crmExportCells(\n resource,\n resource === 'contacts'\n ? (contactCsvRowFromDoc(\n document,\n holderFor(document),\n ) as Record<string, unknown>)\n : document,\n options,\n ),\n ),\n )\n controller.enqueue(encode(`\\n${lines.join('\\n')}`))\n return\n }\n if (phase === 'body') return\n }\n controller.enqueue(encode('\\n'))\n controller.close()\n } catch (error) {\n // Erroring the stream truncates the download rather than\n // completing it short — a half-written file the browser reports\n // as a failed transfer, not a quiet partial handover.\n controller.error(error)\n }\n },\n })\n\n // Ids and counts only, never content: a full copy of an audience\n // leaving the platform is worth a row; who was in it is not ours to log.\n void addAdminAudit(firestore, {\n actorUid: decoded.uid,\n action: 'crm.exported',\n target: `orgs/${orgId}/${resource}`,\n before: null,\n after: { resource, rows: total, hostId: hostId || null },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const stamp = new Date().toISOString().slice(0, 10)\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/csv; charset=utf-8',\n 'Content-Disposition': `attachment; filename=\"${FILE_BASE[resource]}-${stamp}.csv\"`,\n [EXPORT_ROWS_HEADER]: String(total),\n 'Cache-Control': 'no-store, private',\n },\n })\n } catch (error) {\n // A refused credential is a 401, not a fault of ours (AGL-1993). Null\n // for anything else, so a real failure keeps the answer below.\n const unauthenticated = invalidIdTokenResponse(error)\n if (unauthenticated) return unauthenticated\n return json({ error: 'Export failed' }, 500)\n }\n}\n\n"],"names":["checkEntitlement","consentGroupForHost","CRM_COLLECTIONS","csvCell","hostScopeToken","isOrgWideMember","MAX_SCOPE_HOSTS","memberCanSee","memberScopeTokens","scopeTokensForHost","planLabelGrantingFeature","pluginRequestFromWeb","emailUnverifiedResponse","firebaseAdmin","isImpersonationSession","isServerReleaseFlagOnForOrg","lockdownRefusal","memberHasOrgPermission","resolveOrgMembership","FieldPath","FieldValue","addAdminAudit","invalidIdTokenResponse","contactCsvRowFromDoc","crmExportCells","crmExportHeader","isCrmExportResource","contactPrimaryGroup","PAGE_SIZE","HOST_PAGE","HOST_CEILING","NAME_CACHE_CEILING","EXPORT_ROWS_HEADER","json","body","status","Response","ORG_COLLECTION","contacts","companies","deals","tasks","FILE_BASE","leads","crmExportRoute","request","headers","method","query","rawHeaders","error","authorization","idToken","startsWith","slice","length","undefined","orgId","String","resource","hostId","decoded","app","auth","verifyIdToken","email_verified","staff","membership","uid","member","peopleFile","firestore","orgRef","collection","doc","orgSnapshot","get","exists","org","data","locked","plan","reason","code","orgWide","tokens","ownerEmails","Map","entry","docs","email","set","id","ownerEmail","options","assigneeEmail","fields","contactFields","customFields","map","key","label","archivedAt","filter","field","pipelines","names","stages","stage","stageId","pipelineName","stageName","pipelineId","recordNames","resolveNames","wanted","missing","has","kind","size","refs","snapshots","getAll","forEach","snapshot","index","name","recordName","siteNames","leadHostIds","cursor","page","where","orderBy","documentId","limit","startAfter","push","siteName","sources","reference","scoped","total","source","counted","Number","count","holderFor","document","group","groupId","hostIds","encoder","TextEncoder","encode","text","line","cells","join","sourceIndex","phase","stream","ReadableStream","pull","controller","enqueue","exhausted","empty","documents","$id","flatMap","lines","close","actorUid","action","target","before","after","rows","at","serverTimestamp","catch","stamp","Date","toISOString","unauthenticated"],"mappings":"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"}
@@ -14,10 +14,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { canManageOrg, crmInboundAddress, crmInboundDomain, isOrgWideMember } from "@aglyn/aglyn/server";
18
- import { ensureCrmInboundToken, firebaseAdmin, getOrgForHost, logOrgActivity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
17
+ */ import { canManageOrg, inboundMailDomain, isOrgWideMember } from "@aglyn/aglyn/server";
18
+ import { firebaseAdmin, getOrgForHost, logOrgActivity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
19
19
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
20
20
  import { CRM_API_ROUTES } from "../constants/api-routes.js";
21
+ import { crmInboundAddress } from "../model/crm-inbound.js";
22
+ import { ensureCrmInboundToken } from "./crm-inbound-email.js";
21
23
  import { authorizeOrgCaller, readCrmRouteScope } from "./org-caller.js";
22
24
  import { crmSuiteRefusal } from "./suite-gate.js";
23
25
  /**
@@ -168,7 +170,7 @@ export const crmInboundAddressHandler = async (req, res)=>{
168
170
  }
169
171
  const answer = _extends({
170
172
  ok: true,
171
- address: crmInboundAddress(result.token, crmInboundDomain()),
173
+ address: crmInboundAddress(result.token, inboundMailDomain()),
172
174
  createdAtMs: result.createdAtMs
173
175
  }, typeof result.rotatedAtMs === 'number' ? {
174
176
  rotatedAtMs: result.rotatedAtMs
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-address.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgMember,\n canManageOrg,\n crmInboundAddress,\n crmInboundDomain,\n isOrgWideMember,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n ensureCrmInboundToken,\n firebaseAdmin,\n getOrgForHost,\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { CRM_API_ROUTES } from '../constants/api-routes'\nimport { authorizeOrgCaller, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/inbound-address` — the workspace's email capture address\n * (AGL-2657).\n *\n * Body: `{ hostId }` under a site, `{ orgId, hostId? }` at the organization\n * level, and `rotate: true` to replace the token. Answers\n * `{ ok: true, address, createdAtMs, rotatedAtMs?, rotated }`.\n *\n * ## Why a route and not a read of the org document\n *\n * The token is on the org document, which every member may read, and the\n * address is the token in a fixed shape — but the shape's domain is the\n * deployment's, `CRM_INBOUND_DOMAIN`, which a browser does not know, and\n * the token does not exist until somebody asks for the address. So the\n * first ask mints it, inside a transaction, and every ask answers the\n * whole address. The org document is the read side for nothing but the\n * rules, which deny the key to every client.\n *\n * ## Who may ask, and who may rotate\n *\n * Asking takes what opening the CRM takes: `data.manage` on the site's\n * org, for a member who reaches the site — the sender's own gate in\n * `email-send.ts`, restated — or, at the organization level, org-wide\n * reach with the same permission. Rotating is a workspace OWNER or ADMIN's\n * act, because the old address stops filing the moment the new token is\n * written and every mailbox rule that carried it goes quiet: the same bar\n * every CRM setting has, and the erasure route's. Staff are admitted to\n * read as they are on every CRM route, and refused the rotation as the\n * erasure route refuses them — it is the workspace's address to retire.\n */\n\nexport const CRM_INBOUND_ADDRESS_ROUTE = CRM_API_ROUTES.inboundAddress\n\n/** What a successful call answers with. */\nexport interface CrmInboundAddressResponse {\n ok: true\n /** `crm+<token>@<capture domain>`. */\n address: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced the token. */\n rotated: boolean\n}\n\nexport const CRM_INBOUND_ROTATE_REFUSAL =\n 'Rotating the capture address requires a workspace owner or admin.'\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Caller {\n ok: true\n uid: string\n email: string | null\n orgId: string\n /** The org document, for the plan the address belongs to. */\n org: unknown\n /** Whether this caller may replace the token. */\n canRotate: boolean\n}\n\nconst refuse = (status: number, error: string): Refusal => ({ ok: false, status, error })\n\n/**\n * The site variant's caller: a verified token, then `data.manage` on the\n * site's org for a member who reaches the site, staff passing as they do\n * on every CRM route. Rotation needs the org role itself.\n */\nasync function authorizeSiteCaller(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Caller | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] inbound-address could not verify the caller', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(() => null)\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (decoded.staff !== true) {\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member && reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Reading the capture address requires the data.manage permission on this site',\n )\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n orgId,\n org: resolved.org,\n canRotate: canManageOrg(member?.role ?? null),\n }\n}\n\n/** The organization variant's caller, by the org the body names (AGL-2634). */\nasync function authorizeOrgLevelCaller(\n req: PluginApiRequest,\n orgId: string,\n rotate: boolean,\n): Promise<Caller | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: rotate ? 'manage-org' : 'data.manage',\n refusal: rotate\n ? CRM_INBOUND_ROTATE_REFUSAL\n : 'Reading the capture address at the organization level requires the ' +\n 'data.manage permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n orgId,\n org: caller.org,\n // `manage-org` admitted them when they asked to rotate; a reader who\n // did not ask is not told whether they could.\n canRotate: rotate,\n }\n}\n\nexport const crmInboundAddressHandler: 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 body = (\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n ) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const rotate = body['rotate'] === true\n try {\n const caller =\n scope.level === 'org'\n ? await authorizeOrgLevelCaller(req, scope.orgId, rotate)\n : await authorizeSiteCaller(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n if (rotate && !caller.canRotate) {\n res.status(403).json({ error: CRM_INBOUND_ROTATE_REFUSAL })\n return\n }\n // The first ask mints the token, so reading the address is a write on\n // a plan without the suite; the webhook refuses such a plan's mail too.\n const suite = crmSuiteRefusal(caller.org, 'The email capture address')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const result = await ensureCrmInboundToken(firestore, caller.orgId, { rotate })\n if (result.rotated) {\n // The old address went quiet with this write; the feed says who did it.\n await logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n 'Rotated the email capture address',\n { type: 'org' },\n )\n }\n const answer: CrmInboundAddressResponse = {\n ok: true,\n address: crmInboundAddress(result.token, crmInboundDomain()),\n createdAtMs: result.createdAtMs,\n ...(typeof result.rotatedAtMs === 'number' ? { rotatedAtMs: result.rotatedAtMs } : {}),\n rotated: result.rotated,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] inbound-address failed', scope, error)\n res.status(500).json({ error: 'The capture address could not be read.' })\n }\n}\n\nexport default crmInboundAddressHandler\n"],"names":["canManageOrg","crmInboundAddress","crmInboundDomain","isOrgWideMember","ensureCrmInboundToken","firebaseAdmin","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","CRM_API_ROUTES","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","CRM_INBOUND_ADDRESS_ROUTE","inboundAddress","CRM_INBOUND_ROTATE_REFUSAL","refuse","status","error","ok","authorizeSiteCaller","req","hostId","decoded","authorization","String","headers","idToken","startsWith","slice","length","undefined","app","auth","verifyIdToken","console","resolved","catch","orgId","membership","uid","member","staff","reaches","Boolean","hostAccess","allowed","email","org","canRotate","role","authorizeOrgLevelCaller","rotate","caller","needs","refusal","crmInboundAddressHandler","res","method","setHeader","json","body","JSON","parse","scope","level","suite","firestore","result","rotated","type","answer","address","token","createdAtMs","rotatedAtMs"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-address.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgMember,\n canManageOrg,\n inboundMailDomain,\n isOrgWideMember,\n type PluginApiHandler,\n type PluginApiRequest,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { CRM_API_ROUTES } from '../constants/api-routes'\nimport { crmInboundAddress } from '../model/crm-inbound'\nimport { ensureCrmInboundToken } from './crm-inbound-email'\nimport { authorizeOrgCaller, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/inbound-address` — the workspace's email capture address\n * (AGL-2657).\n *\n * Body: `{ hostId }` under a site, `{ orgId, hostId? }` at the organization\n * level, and `rotate: true` to replace the token. Answers\n * `{ ok: true, address, createdAtMs, rotatedAtMs?, rotated }`.\n *\n * ## Why a route and not a read of the org document\n *\n * The token is on the org document, which every member may read, and the\n * address is the token in a fixed shape — but the shape's domain is the\n * deployment's, `CRM_INBOUND_DOMAIN`, which a browser does not know, and\n * the token does not exist until somebody asks for the address. So the\n * first ask mints it, inside a transaction, and every ask answers the\n * whole address. The org document is the read side for nothing but the\n * rules, which deny the key to every client.\n *\n * ## Who may ask, and who may rotate\n *\n * Asking takes what opening the CRM takes: `data.manage` on the site's\n * org, for a member who reaches the site — the sender's own gate in\n * `email-send.ts`, restated — or, at the organization level, org-wide\n * reach with the same permission. Rotating is a workspace OWNER or ADMIN's\n * act, because the old address stops filing the moment the new token is\n * written and every mailbox rule that carried it goes quiet: the same bar\n * every CRM setting has, and the erasure route's. Staff are admitted to\n * read as they are on every CRM route, and refused the rotation as the\n * erasure route refuses them — it is the workspace's address to retire.\n */\n\nexport const CRM_INBOUND_ADDRESS_ROUTE = CRM_API_ROUTES.inboundAddress\n\n/** What a successful call answers with. */\nexport interface CrmInboundAddressResponse {\n ok: true\n /** `crm+<token>@<capture domain>`. */\n address: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced the token. */\n rotated: boolean\n}\n\nexport const CRM_INBOUND_ROTATE_REFUSAL =\n 'Rotating the capture address requires a workspace owner or admin.'\n\ntype Refusal = { ok: false; status: number; error: string }\n\ninterface Caller {\n ok: true\n uid: string\n email: string | null\n orgId: string\n /** The org document, for the plan the address belongs to. */\n org: unknown\n /** Whether this caller may replace the token. */\n canRotate: boolean\n}\n\nconst refuse = (status: number, error: string): Refusal => ({ ok: false, status, error })\n\n/**\n * The site variant's caller: a verified token, then `data.manage` on the\n * site's org for a member who reaches the site, staff passing as they do\n * on every CRM route. Rotation needs the org role itself.\n */\nasync function authorizeSiteCaller(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Caller | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] inbound-address could not verify the caller', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(() => null)\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (decoded.staff !== true) {\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member && reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Reading the capture address requires the data.manage permission on this site',\n )\n }\n }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n orgId,\n org: resolved.org,\n canRotate: canManageOrg(member?.role ?? null),\n }\n}\n\n/** The organization variant's caller, by the org the body names (AGL-2634). */\nasync function authorizeOrgLevelCaller(\n req: PluginApiRequest,\n orgId: string,\n rotate: boolean,\n): Promise<Caller | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: rotate ? 'manage-org' : 'data.manage',\n refusal: rotate\n ? CRM_INBOUND_ROTATE_REFUSAL\n : 'Reading the capture address at the organization level requires the ' +\n 'data.manage permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n orgId,\n org: caller.org,\n // `manage-org` admitted them when they asked to rotate; a reader who\n // did not ask is not told whether they could.\n canRotate: rotate,\n }\n}\n\nexport const crmInboundAddressHandler: 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 body = (\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n ) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const rotate = body['rotate'] === true\n try {\n const caller =\n scope.level === 'org'\n ? await authorizeOrgLevelCaller(req, scope.orgId, rotate)\n : await authorizeSiteCaller(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n if (rotate && !caller.canRotate) {\n res.status(403).json({ error: CRM_INBOUND_ROTATE_REFUSAL })\n return\n }\n // The first ask mints the token, so reading the address is a write on\n // a plan without the suite; the webhook refuses such a plan's mail too.\n const suite = crmSuiteRefusal(caller.org, 'The email capture address')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const result = await ensureCrmInboundToken(firestore, caller.orgId, { rotate })\n if (result.rotated) {\n // The old address went quiet with this write; the feed says who did it.\n await logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n 'Rotated the email capture address',\n { type: 'org' },\n )\n }\n const answer: CrmInboundAddressResponse = {\n ok: true,\n address: crmInboundAddress(result.token, inboundMailDomain()),\n createdAtMs: result.createdAtMs,\n ...(typeof result.rotatedAtMs === 'number' ? { rotatedAtMs: result.rotatedAtMs } : {}),\n rotated: result.rotated,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] inbound-address failed', scope, error)\n res.status(500).json({ error: 'The capture address could not be read.' })\n }\n}\n\nexport default crmInboundAddressHandler\n"],"names":["canManageOrg","inboundMailDomain","isOrgWideMember","firebaseAdmin","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","CRM_API_ROUTES","crmInboundAddress","ensureCrmInboundToken","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","CRM_INBOUND_ADDRESS_ROUTE","inboundAddress","CRM_INBOUND_ROTATE_REFUSAL","refuse","status","error","ok","authorizeSiteCaller","req","hostId","decoded","authorization","String","headers","idToken","startsWith","slice","length","undefined","app","auth","verifyIdToken","console","resolved","catch","orgId","membership","uid","member","staff","reaches","Boolean","hostAccess","allowed","email","org","canRotate","role","authorizeOrgLevelCaller","rotate","caller","needs","refusal","crmInboundAddressHandler","res","method","setHeader","json","body","JSON","parse","scope","level","suite","firestore","result","rotated","type","answer","address","token","createdAtMs","rotatedAtMs"],"mappings":"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"}
@@ -0,0 +1,74 @@
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 ReceivedEmailSource } from '@aglyn/shared-util-email';
18
+ /**
19
+ * THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.
20
+ *
21
+ * The mail provider receives mail for the capture domain and announces each
22
+ * message with a notification, signed the Svix way. This route verifies the
23
+ * signature, asks the provider which message the notification announces and
24
+ * who it was for (`readInboundMailEvent`), finds the capture token among
25
+ * those recipients, resolves the workspace the token names, has the
26
+ * provider read the message, and files it on the record of whoever it was
27
+ * with — see
28
+ * `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the
29
+ * rules. A message nobody in the workspace knows is noted in the org's
30
+ * feed by its sender's domain and dropped.
31
+ *
32
+ * ## A machine's route
33
+ *
34
+ * The plugin dispatcher gates a path on the org's release flag, and
35
+ * resolves the org from a `hostId` the caller names. A webhook names no
36
+ * site — the workspace is inside the address — so gated that way every
37
+ * delivery would be a subject-less request, which a partial rollout
38
+ * refuses. So the CRM registers it as a MACHINE's route (`machine: true`,
39
+ * AGL-3080): the dispatcher leaves those gates to it, the org is resolved
40
+ * from the token first, and the plan and the flag are judged for THAT org,
41
+ * which is the gate the CRM actually ships behind.
42
+ *
43
+ * ## What the provider is told
44
+ *
45
+ * A signed event is never answered with an error the provider would retry
46
+ * forever: an event that is not a received message, a token nobody holds,
47
+ * a workspace whose CRM is off, a message that matched no record — each
48
+ * is acknowledged, `200` or `202`, with a `reason` in the body for a
49
+ * person reading the delivery log. Only a bad signature (`401`), a missing
50
+ * secret or a provider that cannot read received mail (`501`), and a
51
+ * failure of ours (`500`) are refused, and a `500` is the one the provider
52
+ * SHOULD retry.
53
+ *
54
+ * ## The body never reaches a log
55
+ *
56
+ * The message is read into memory, reduced to the row's bounded excerpt,
57
+ * and released. Nothing here prints it, and the feed line for an
58
+ * unmatched message carries the sender's domain and nothing else: a
59
+ * stranger's mail to a stale address is not the workspace's to keep.
60
+ *
61
+ * ## Two secrets
62
+ *
63
+ * A provider's webhook endpoint carries its own signing secret. The capture
64
+ * endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is
65
+ * tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —
66
+ * is accepted too, for a deployment that subscribed received mail on the
67
+ * endpoint it already had. The check itself is the one the
68
+ * delivery-events webhook runs.
69
+ */
70
+ /** The signing secrets a delivery may carry, dedicated first. */
71
+ export declare function inboundWebhookSecrets(env?: Record<string, string | undefined>): string[];
72
+ /** The spec's seam: reads without the provider. */
73
+ export declare function setInboundReaderForTesting(reader: ReceivedEmailSource | null): void;
74
+ export declare function crmInboundRoute(request: Request): Promise<Response>;
@@ -0,0 +1,260 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { inboundMailDomain, isReleaseFlagOnForOrg, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveEffectivePlan, resolveOrgEntitlements } from "@aglyn/aglyn/server";
18
+ import { mailProviderReads, readInboundMailEvent } from "@aglyn/shared-util-email";
19
+ // By its own path, not the barrel: the check holds a `crypto` HMAC, and the
20
+ // barrel is reached from the browser through the campaign model.
21
+ import { verifySvixSignature } from "@aglyn/shared-util-email/svix-signature";
22
+ import { firebaseAdmin, getServerReleaseFlagValues, logOrgActivity } from "@aglyn/tenant-data-admin";
23
+ import { loadMemberAddressRoster } from "@aglyn/tenant-data-admin/server/member-email-aliases";
24
+ import { CRM_INBOUND_CEILING_ACTION, CRM_INBOUND_UNMATCHED_ACTION, crmInboundTokensIn } from "../model/crm-inbound.js";
25
+ import { crmInboundHostIds, fileCrmInboundEmail, findOrgByCrmInboundToken } from "./crm-inbound-email.js";
26
+ // lockdown-423: exempt — a provider webhook (Svix-signed), no user caller; it files one received message on the record it was with and reads nothing a locked org could lose.
27
+ /**
28
+ * THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.
29
+ *
30
+ * The mail provider receives mail for the capture domain and announces each
31
+ * message with a notification, signed the Svix way. This route verifies the
32
+ * signature, asks the provider which message the notification announces and
33
+ * who it was for (`readInboundMailEvent`), finds the capture token among
34
+ * those recipients, resolves the workspace the token names, has the
35
+ * provider read the message, and files it on the record of whoever it was
36
+ * with — see
37
+ * `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the
38
+ * rules. A message nobody in the workspace knows is noted in the org's
39
+ * feed by its sender's domain and dropped.
40
+ *
41
+ * ## A machine's route
42
+ *
43
+ * The plugin dispatcher gates a path on the org's release flag, and
44
+ * resolves the org from a `hostId` the caller names. A webhook names no
45
+ * site — the workspace is inside the address — so gated that way every
46
+ * delivery would be a subject-less request, which a partial rollout
47
+ * refuses. So the CRM registers it as a MACHINE's route (`machine: true`,
48
+ * AGL-3080): the dispatcher leaves those gates to it, the org is resolved
49
+ * from the token first, and the plan and the flag are judged for THAT org,
50
+ * which is the gate the CRM actually ships behind.
51
+ *
52
+ * ## What the provider is told
53
+ *
54
+ * A signed event is never answered with an error the provider would retry
55
+ * forever: an event that is not a received message, a token nobody holds,
56
+ * a workspace whose CRM is off, a message that matched no record — each
57
+ * is acknowledged, `200` or `202`, with a `reason` in the body for a
58
+ * person reading the delivery log. Only a bad signature (`401`), a missing
59
+ * secret or a provider that cannot read received mail (`501`), and a
60
+ * failure of ours (`500`) are refused, and a `500` is the one the provider
61
+ * SHOULD retry.
62
+ *
63
+ * ## The body never reaches a log
64
+ *
65
+ * The message is read into memory, reduced to the row's bounded excerpt,
66
+ * and released. Nothing here prints it, and the feed line for an
67
+ * unmatched message carries the sender's domain and nothing else: a
68
+ * stranger's mail to a stale address is not the workspace's to keep.
69
+ *
70
+ * ## Two secrets
71
+ *
72
+ * A provider's webhook endpoint carries its own signing secret. The capture
73
+ * endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is
74
+ * tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —
75
+ * is accepted too, for a deployment that subscribed received mail on the
76
+ * endpoint it already had. The check itself is the one the
77
+ * delivery-events webhook runs.
78
+ */ /** The signing secrets a delivery may carry, dedicated first. */ export function inboundWebhookSecrets(env = process.env) {
79
+ const secrets = [
80
+ env['CRM_INBOUND_WEBHOOK_SECRET'],
81
+ env['RESEND_WEBHOOK_SECRET']
82
+ ].map((value)=>String(value != null ? value : '').trim()).filter((value)=>value !== '');
83
+ return [
84
+ ...new Set(secrets)
85
+ ];
86
+ }
87
+ /**
88
+ * A reader a spec hands in through the module's seam, in place of the
89
+ * provider's. `null` — always, outside a spec — reads through the provider.
90
+ */ let readerOverride = null;
91
+ /** The spec's seam: reads without the provider. */ export function setInboundReaderForTesting(reader) {
92
+ readerOverride = reader;
93
+ }
94
+ const acknowledge = (status, body)=>Response.json(body, {
95
+ status
96
+ });
97
+ export async function crmInboundRoute(request) {
98
+ var _req_rawBody, _headers_svixid, _headers_svixtimestamp, _headers_svixsignature;
99
+ const req = await pluginRequestFromWeb(request);
100
+ if (req.method !== 'POST') {
101
+ return Response.json({
102
+ error: 'Method not allowed'
103
+ }, {
104
+ status: 405
105
+ });
106
+ }
107
+ const secrets = inboundWebhookSecrets();
108
+ if (!secrets.length) {
109
+ return Response.json({
110
+ error: 'Email capture is not configured'
111
+ }, {
112
+ status: 501
113
+ });
114
+ }
115
+ const payload = Buffer.from((_req_rawBody = req.rawBody) != null ? _req_rawBody : '', 'utf8');
116
+ const headers = req.headers;
117
+ const svixId = String((_headers_svixid = headers['svix-id']) != null ? _headers_svixid : '');
118
+ const svixTimestamp = String((_headers_svixtimestamp = headers['svix-timestamp']) != null ? _headers_svixtimestamp : '');
119
+ const svixSignature = String((_headers_svixsignature = headers['svix-signature']) != null ? _headers_svixsignature : '');
120
+ const signed = svixId !== '' && svixTimestamp !== '' && secrets.some((secret)=>verifySvixSignature(secret, svixId, svixTimestamp, payload, svixSignature));
121
+ if (!signed) return Response.json({
122
+ error: 'Bad signature'
123
+ }, {
124
+ status: 401
125
+ });
126
+ let event;
127
+ try {
128
+ event = JSON.parse(payload.toString('utf8'));
129
+ } catch (unused) {
130
+ return acknowledge(200, {
131
+ ignored: true,
132
+ reason: 'not-json'
133
+ });
134
+ }
135
+ const inbound = readInboundMailEvent(event);
136
+ if (!inbound) return acknowledge(200, {
137
+ ignored: true,
138
+ reason: 'not-received-event'
139
+ });
140
+ const domain = inboundMailDomain();
141
+ const tokens = crmInboundTokensIn(inbound.recipients, domain);
142
+ if (!tokens.length) return acknowledge(202, {
143
+ filed: false,
144
+ reason: 'no-token'
145
+ });
146
+ try {
147
+ var _resolveOrgEntitlements_features;
148
+ const firestore = firebaseAdmin.app().firestore();
149
+ // The first token that names a workspace: a message copied to two
150
+ // workspaces' addresses is filed on the first, which is the only
151
+ // reading that keeps one message one row.
152
+ let resolved = null;
153
+ for (const token of tokens){
154
+ resolved = await findOrgByCrmInboundToken(firestore, token);
155
+ if (resolved) break;
156
+ }
157
+ if (!resolved) return acknowledge(202, {
158
+ filed: false,
159
+ reason: 'unknown-token'
160
+ });
161
+ const { orgId, org } = resolved;
162
+ // The gate the CRM ships behind, judged for the org the token named:
163
+ // the plan carries the CRM and the release flag is on for it.
164
+ if (((_resolveOrgEntitlements_features = resolveOrgEntitlements(org).features) == null ? void 0 : _resolveOrgEntitlements_features['crm']) !== true) {
165
+ return acknowledge(202, {
166
+ filed: false,
167
+ reason: 'not-entitled'
168
+ });
169
+ }
170
+ const flagValues = await getServerReleaseFlagValues();
171
+ const flagOn = isReleaseFlagOnForOrg('release_crm', flagValues['release_crm'], orgId, parseOrgReleaseFlagOverrides(org['releaseFlags']), resolveEffectivePlan(org));
172
+ if (!flagOn) return acknowledge(202, {
173
+ filed: false,
174
+ reason: 'release-flag'
175
+ });
176
+ const reads = mailProviderReads();
177
+ const unmet = reads.unmet();
178
+ if (unmet) {
179
+ console.warn(`[crm] inbound: received mail cannot be read — ${unmet}`);
180
+ return Response.json({
181
+ error: `Email capture cannot read received mail. ${unmet}`
182
+ }, {
183
+ status: 501
184
+ });
185
+ }
186
+ const message = await (readerOverride != null ? readerOverride : reads.received)(inbound.id);
187
+ if (!message) return acknowledge(202, {
188
+ filed: false,
189
+ reason: 'message-gone'
190
+ });
191
+ // The roster carries each member's confirmed aliases beside the address
192
+ // they sign in with (AGL-2975), so a send from an outbound-domain alias
193
+ // is read as the member's and filed on the person it was written to.
194
+ const [members, hostIds] = await Promise.all([
195
+ loadMemberAddressRoster(firestore, orgId),
196
+ crmInboundHostIds(firestore, orgId)
197
+ ]);
198
+ const result = await fileCrmInboundEmail(firestore, {
199
+ orgId,
200
+ org,
201
+ message,
202
+ domain,
203
+ members,
204
+ hostIds
205
+ });
206
+ switch(result.outcome){
207
+ case 'filed':
208
+ return acknowledge(200, {
209
+ filed: true,
210
+ activityId: result.activityId,
211
+ direction: result.match.direction,
212
+ kind: result.match.kind
213
+ });
214
+ case 'duplicate':
215
+ return acknowledge(200, {
216
+ filed: false,
217
+ reason: 'duplicate',
218
+ activityId: result.activityId
219
+ });
220
+ case 'ceiling':
221
+ await logOrgActivity(orgId, {
222
+ uid: null
223
+ }, CRM_INBOUND_CEILING_ACTION, _extends({
224
+ type: result.match.kind === 'lead' ? 'lead' : 'contact'
225
+ }, result.match.link.contactId ? {
226
+ id: String(result.match.link.contactId)
227
+ } : result.match.link.leadId ? {
228
+ id: String(result.match.link.leadId)
229
+ } : {}));
230
+ return acknowledge(202, {
231
+ filed: false,
232
+ reason: 'ceiling'
233
+ });
234
+ case 'unmatched':
235
+ default:
236
+ // The sender's domain and nothing else: the feed says a message
237
+ // arrived that nobody could be matched to, never what it said.
238
+ await logOrgActivity(orgId, {
239
+ uid: null
240
+ }, CRM_INBOUND_UNMATCHED_ACTION, _extends({
241
+ type: 'contact'
242
+ }, result.senderDomain ? {
243
+ name: result.senderDomain
244
+ } : {}));
245
+ return acknowledge(202, {
246
+ filed: false,
247
+ reason: 'unmatched'
248
+ });
249
+ }
250
+ } catch (error) {
251
+ console.error('[crm] inbound capture failed', svixId, error == null ? void 0 : error.message);
252
+ return Response.json({
253
+ error: 'The message could not be filed'
254
+ }, {
255
+ status: 500
256
+ });
257
+ }
258
+ }
259
+
260
+ //# sourceMappingURL=inbound-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/inbound-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n inboundMailDomain,\n isReleaseFlagOnForOrg,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport {\n type ReceivedEmailSource,\n mailProviderReads,\n readInboundMailEvent,\n} from '@aglyn/shared-util-email'\n// By its own path, not the barrel: the check holds a `crypto` HMAC, and the\n// barrel is reached from the browser through the campaign model.\nimport { verifySvixSignature } from '@aglyn/shared-util-email/svix-signature'\nimport {\n firebaseAdmin,\n getServerReleaseFlagValues,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\nimport { loadMemberAddressRoster } from '@aglyn/tenant-data-admin/server/member-email-aliases'\nimport {\n CRM_INBOUND_CEILING_ACTION,\n CRM_INBOUND_UNMATCHED_ACTION,\n crmInboundTokensIn,\n} from '../model/crm-inbound'\nimport {\n crmInboundHostIds,\n fileCrmInboundEmail,\n findOrgByCrmInboundToken,\n} from './crm-inbound-email'\n\n// lockdown-423: exempt — a provider webhook (Svix-signed), no user caller; it files one received message on the record it was with and reads nothing a locked org could lose.\n\n/**\n * THE CAPTURE WEBHOOK (AGL-2657): `POST /api/crm/inbound`.\n *\n * The mail provider receives mail for the capture domain and announces each\n * message with a notification, signed the Svix way. This route verifies the\n * signature, asks the provider which message the notification announces and\n * who it was for (`readInboundMailEvent`), finds the capture token among\n * those recipients, resolves the workspace the token names, has the\n * provider read the message, and files it on the record of whoever it was\n * with — see\n * `fileCrmInboundEmail` for the matching and `crm-inbound.ts` for the\n * rules. A message nobody in the workspace knows is noted in the org's\n * feed by its sender's domain and dropped.\n *\n * ## A machine's route\n *\n * The plugin dispatcher gates a path on the org's release flag, and\n * resolves the org from a `hostId` the caller names. A webhook names no\n * site — the workspace is inside the address — so gated that way every\n * delivery would be a subject-less request, which a partial rollout\n * refuses. So the CRM registers it as a MACHINE's route (`machine: true`,\n * AGL-3080): the dispatcher leaves those gates to it, the org is resolved\n * from the token first, and the plan and the flag are judged for THAT org,\n * which is the gate the CRM actually ships behind.\n *\n * ## What the provider is told\n *\n * A signed event is never answered with an error the provider would retry\n * forever: an event that is not a received message, a token nobody holds,\n * a workspace whose CRM is off, a message that matched no record — each\n * is acknowledged, `200` or `202`, with a `reason` in the body for a\n * person reading the delivery log. Only a bad signature (`401`), a missing\n * secret or a provider that cannot read received mail (`501`), and a\n * failure of ours (`500`) are refused, and a `500` is the one the provider\n * SHOULD retry.\n *\n * ## The body never reaches a log\n *\n * The message is read into memory, reduced to the row's bounded excerpt,\n * and released. Nothing here prints it, and the feed line for an\n * unmatched message carries the sender's domain and nothing else: a\n * stranger's mail to a stale address is not the workspace's to keep.\n *\n * ## Two secrets\n *\n * A provider's webhook endpoint carries its own signing secret. The capture\n * endpoint is its own subscription, so `CRM_INBOUND_WEBHOOK_SECRET` is\n * tried first; `RESEND_WEBHOOK_SECRET` — the delivery-events endpoint's —\n * is accepted too, for a deployment that subscribed received mail on the\n * endpoint it already had. The check itself is the one the\n * delivery-events webhook runs.\n */\n\n/** The signing secrets a delivery may carry, dedicated first. */\nexport function inboundWebhookSecrets(\n env: Record<string, string | undefined> = process.env,\n): string[] {\n const secrets = [env['CRM_INBOUND_WEBHOOK_SECRET'], env['RESEND_WEBHOOK_SECRET']]\n .map((value) => String(value ?? '').trim())\n .filter((value) => value !== '')\n return [...new Set(secrets)]\n}\n\n/**\n * A reader a spec hands in through the module's seam, in place of the\n * provider's. `null` — always, outside a spec — reads through the provider.\n */\nlet readerOverride: ReceivedEmailSource | null = null\n\n/** The spec's seam: reads without the provider. */\nexport function setInboundReaderForTesting(reader: ReceivedEmailSource | null): void {\n readerOverride = reader\n}\n\nconst acknowledge = (status: number, body: Record<string, unknown>): Response =>\n Response.json(body, { status })\n\nexport async function crmInboundRoute(request: Request): Promise<Response> {\n const req = await pluginRequestFromWeb(request)\n if (req.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n const secrets = inboundWebhookSecrets()\n if (!secrets.length) {\n return Response.json({ error: 'Email capture is not configured' }, { status: 501 })\n }\n const payload = Buffer.from(req.rawBody ?? '', 'utf8')\n const headers = req.headers as Partial<Record<string, string>>\n const svixId = String(headers['svix-id'] ?? '')\n const svixTimestamp = String(headers['svix-timestamp'] ?? '')\n const svixSignature = String(headers['svix-signature'] ?? '')\n const signed =\n svixId !== '' &&\n svixTimestamp !== '' &&\n secrets.some((secret) =>\n verifySvixSignature(secret, svixId, svixTimestamp, payload, svixSignature),\n )\n if (!signed) return Response.json({ error: 'Bad signature' }, { status: 401 })\n\n let event: unknown\n try {\n event = JSON.parse(payload.toString('utf8'))\n } catch {\n return acknowledge(200, { ignored: true, reason: 'not-json' })\n }\n const inbound = readInboundMailEvent(event)\n if (!inbound) return acknowledge(200, { ignored: true, reason: 'not-received-event' })\n\n const domain = inboundMailDomain()\n const tokens = crmInboundTokensIn(inbound.recipients, domain)\n if (!tokens.length) return acknowledge(202, { filed: false, reason: 'no-token' })\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n // The first token that names a workspace: a message copied to two\n // workspaces' addresses is filed on the first, which is the only\n // reading that keeps one message one row.\n let resolved: Awaited<ReturnType<typeof findOrgByCrmInboundToken>> = null\n for (const token of tokens) {\n resolved = await findOrgByCrmInboundToken(firestore, token)\n if (resolved) break\n }\n if (!resolved) return acknowledge(202, { filed: false, reason: 'unknown-token' })\n const { orgId, org } = resolved\n\n // The gate the CRM ships behind, judged for the org the token named:\n // the plan carries the CRM and the release flag is on for it.\n if (resolveOrgEntitlements(org as never).features?.['crm'] !== true) {\n return acknowledge(202, { filed: false, reason: 'not-entitled' })\n }\n const flagValues = await getServerReleaseFlagValues()\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org as never),\n )\n if (!flagOn) return acknowledge(202, { filed: false, reason: 'release-flag' })\n\n const reads = mailProviderReads()\n const unmet = reads.unmet()\n if (unmet) {\n console.warn(`[crm] inbound: received mail cannot be read — ${unmet}`)\n return Response.json(\n { error: `Email capture cannot read received mail. ${unmet}` },\n { status: 501 },\n )\n }\n const message = await (readerOverride ?? reads.received)(inbound.id)\n if (!message) return acknowledge(202, { filed: false, reason: 'message-gone' })\n\n // The roster carries each member's confirmed aliases beside the address\n // they sign in with (AGL-2975), so a send from an outbound-domain alias\n // is read as the member's and filed on the person it was written to.\n const [members, hostIds] = await Promise.all([\n loadMemberAddressRoster(firestore, orgId),\n crmInboundHostIds(firestore, orgId),\n ])\n const result = await fileCrmInboundEmail(firestore, {\n orgId,\n org,\n message,\n domain,\n members,\n hostIds,\n })\n\n switch (result.outcome) {\n case 'filed':\n return acknowledge(200, {\n filed: true,\n activityId: result.activityId,\n direction: result.match.direction,\n kind: result.match.kind,\n })\n case 'duplicate':\n return acknowledge(200, { filed: false, reason: 'duplicate', activityId: result.activityId })\n case 'ceiling':\n await logOrgActivity(orgId, { uid: null }, CRM_INBOUND_CEILING_ACTION, {\n type: result.match.kind === 'lead' ? 'lead' : 'contact',\n ...(result.match.link.contactId\n ? { id: String(result.match.link.contactId) }\n : result.match.link.leadId\n ? { id: String(result.match.link.leadId) }\n : {}),\n })\n return acknowledge(202, { filed: false, reason: 'ceiling' })\n case 'unmatched':\n default:\n // The sender's domain and nothing else: the feed says a message\n // arrived that nobody could be matched to, never what it said.\n await logOrgActivity(orgId, { uid: null }, CRM_INBOUND_UNMATCHED_ACTION, {\n type: 'contact',\n ...(result.senderDomain ? { name: result.senderDomain } : {}),\n })\n return acknowledge(202, { filed: false, reason: 'unmatched' })\n }\n } catch (error) {\n console.error('[crm] inbound capture failed', svixId, (error as Error)?.message)\n return Response.json({ error: 'The message could not be filed' }, { status: 500 })\n }\n}\n"],"names":["inboundMailDomain","isReleaseFlagOnForOrg","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveEffectivePlan","resolveOrgEntitlements","mailProviderReads","readInboundMailEvent","verifySvixSignature","firebaseAdmin","getServerReleaseFlagValues","logOrgActivity","loadMemberAddressRoster","CRM_INBOUND_CEILING_ACTION","CRM_INBOUND_UNMATCHED_ACTION","crmInboundTokensIn","crmInboundHostIds","fileCrmInboundEmail","findOrgByCrmInboundToken","inboundWebhookSecrets","env","process","secrets","map","value","String","trim","filter","Set","readerOverride","setInboundReaderForTesting","reader","acknowledge","status","body","Response","json","crmInboundRoute","request","req","headers","method","error","length","payload","Buffer","from","rawBody","svixId","svixTimestamp","svixSignature","signed","some","secret","event","JSON","parse","toString","ignored","reason","inbound","domain","tokens","recipients","filed","firestore","app","resolved","token","orgId","org","features","flagValues","flagOn","reads","unmet","console","warn","message","received","id","members","hostIds","Promise","all","result","outcome","activityId","direction","match","kind","uid","type","link","contactId","leadId","senderDomain","name"],"mappings":"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"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { contactCampaignFieldPath, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
16
+ */ import { contactContainerFieldPath, readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
17
17
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
18
  import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
19
19
  import { readLeadForHost } from "@aglyn/tenant-data-admin/server/host-visitor-records";
@@ -42,13 +42,13 @@ import { fileCampaignFilingActivities, orgCampaignRefs } from "./campaign-filing
42
42
  */ export async function carryLeadCampaignsToContact(firestore, request) {
43
43
  try {
44
44
  const lead = await readLeadForHost(request.hostId, request.leadId);
45
- const campaignIds = readCampaignIds(lead ? lead.data() : null);
45
+ const campaignIds = readContainerIds(lead ? lead.data() : null, 'campaign');
46
46
  if (!campaignIds.length) return {
47
47
  campaigns: 0
48
48
  };
49
49
  const group = await consentGroupForSite(request.hostId);
50
50
  await firestore.collection('orgs').doc(request.orgId).collection('contacts').doc(request.contactId).update({
51
- [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(...campaignIds),
51
+ [contactContainerFieldPath(group.groupId, 'campaign')]: FieldValue.arrayUnion(...campaignIds),
52
52
  updatedAt: FieldValue.serverTimestamp()
53
53
  });
54
54
  /*
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { readLeadForHost } from '@aglyn/tenant-data-admin/server/host-visitor-records'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, orgCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await readLeadForHost(request.hostId, request.leadId)\n const campaignIds = readCampaignIds(\n lead ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await orgCampaignRefs(firestore, request.orgId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","readLeadForHost","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","orgCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","hostId","leadId","campaignIds","data","length","campaigns","group","collection","doc","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,eAAe,QAAQ,uDAAsD;AACtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,eAAe,QAAQ,gCAA4B;AAE1F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMR,gBAAgBO,QAAQE,MAAM,EAAEF,QAAQG,MAAM;QACjE,MAAMC,cAAcd,gBAClBW,OAAQA,KAAKI,IAAI,KAAiC;QAEpD,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBQ,QAAQE,MAAM;QACtD,MAAMH,UACHU,UAAU,CAAC,QACXC,GAAG,CAACV,QAAQW,KAAK,EACjBF,UAAU,CAAC,YACXC,GAAG,CAACV,QAAQY,SAAS,EACrBC,MAAM,CAAC;YACN,CAACxB,yBAAyBmB,MAAMM,OAAO,EAAE,EAAEpB,WAAWqB,UAAU,IAC3DX;YAELY,WAAWtB,WAAWuB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMrB,6BAA6BG,WAAW;YAC5CY,OAAOX,QAAQW,KAAK;YACpBT,QAAQF,QAAQE,MAAM;YACtBgB,MAAM;gBAAEN,WAAWZ,QAAQY,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDZ,WAAW,MAAMV,gBAAgBE,WAAWC,QAAQW,KAAK,EAAEP,aAAagB,KAAK,CAAC,IAC5EhB,YAAYiB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQjC;YACRkC,WAAW,CAAC,MAAM,EAAE7B,QAAQG,MAAM,CAAC,CAAC,EAAEH,QAAQY,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEL,WAAWH,YAAYE,MAAM;QAAC;IACzC,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACA9B,QAAQE,MAAM,EACdF,QAAQG,MAAM,EACd2B;QAEF,OAAO;YAAEvB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASyB,+BACdhC,OAGC;IAED,OAAOF,4BAA4BP,cAAc0C,GAAG,GAAGlC,SAAS,IAAIC;AACtE"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactContainerFieldPath,\n readContainerIds,\n} from '@aglyn/aglyn/app-utils/container-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { readLeadForHost } from '@aglyn/tenant-data-admin/server/host-visitor-records'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, orgCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await readLeadForHost(request.hostId, request.leadId)\n const campaignIds = readContainerIds(\n lead ? (lead.data() as Record<string, unknown>) : null,\n 'campaign',\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactContainerFieldPath(group.groupId, 'campaign')]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await orgCampaignRefs(firestore, request.orgId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactContainerFieldPath","readContainerIds","firebaseAdmin","consentGroupForSite","readLeadForHost","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","orgCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","hostId","leadId","campaignIds","data","length","campaigns","group","collection","doc","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,EACzBC,gBAAgB,QACX,8CAA6C;AAKpD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,eAAe,QAAQ,uDAAsD;AACtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,eAAe,QAAQ,gCAA4B;AAE1F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMR,gBAAgBO,QAAQE,MAAM,EAAEF,QAAQG,MAAM;QACjE,MAAMC,cAAcd,iBAClBW,OAAQA,KAAKI,IAAI,KAAiC,MAClD;QAEF,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBQ,QAAQE,MAAM;QACtD,MAAMH,UACHU,UAAU,CAAC,QACXC,GAAG,CAACV,QAAQW,KAAK,EACjBF,UAAU,CAAC,YACXC,GAAG,CAACV,QAAQY,SAAS,EACrBC,MAAM,CAAC;YACN,CAACxB,0BAA0BmB,MAAMM,OAAO,EAAE,YAAY,EAAEpB,WAAWqB,UAAU,IACxEX;YAELY,WAAWtB,WAAWuB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMrB,6BAA6BG,WAAW;YAC5CY,OAAOX,QAAQW,KAAK;YACpBT,QAAQF,QAAQE,MAAM;YACtBgB,MAAM;gBAAEN,WAAWZ,QAAQY,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDZ,WAAW,MAAMV,gBAAgBE,WAAWC,QAAQW,KAAK,EAAEP,aAAagB,KAAK,CAAC,IAC5EhB,YAAYiB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQjC;YACRkC,WAAW,CAAC,MAAM,EAAE7B,QAAQG,MAAM,CAAC,CAAC,EAAEH,QAAQY,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEL,WAAWH,YAAYE,MAAM;QAAC;IACzC,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACA9B,QAAQE,MAAM,EACdF,QAAQG,MAAM,EACd2B;QAEF,OAAO;YAAEvB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASyB,+BACdhC,OAGC;IAED,OAAOF,4BAA4BP,cAAc0C,GAAG,GAAGlC,SAAS,IAAIC;AACtE"}