@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
@@ -14,12 +14,13 @@ 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 { consentGroupForHost, CRM_COLLECTIONS, crmScopeTokens, crmTaskListFields, crmTaskReminderAfterEdit, crmTaskReminderPending, isOrgWideMember, memberCanSee } from "@aglyn/aglyn/server";
17
+ */ import { consentGroupForHost, CRM_COLLECTIONS, crmScopeTokens, crmTaskListFields, crmTaskReminderAfterEdit, crmTaskReminderPending, crmTaskReminderWhen, isOrgWideMember, memberCanSee } from "@aglyn/aglyn/server";
18
18
  import { crmNextActivityLinksOf, firebaseAdmin, getOrgForHost, memberHasOrgPermission, notifyUsers, recomputeCrmNextTaskAt, resolveOrgMembership } from "@aglyn/tenant-data-admin";
19
19
  import { emitHostEvent } from "@aglyn/tenant-runtime";
20
20
  import { FieldValue } from "firebase-admin/firestore";
21
21
  import { CRM_TASK_BATCH_MAX, readCrmTaskFields } from "../model/task-routes.js";
22
22
  import { CRM_ORG_TASK_SCOPE } from "../model/task-scope.js";
23
+ import { digestTimeZone } from "../model/crm-digest.js";
23
24
  import { authorizeOrgCaller, readCrmRouteScope } from "./org-caller.js";
24
25
  import { crmSuiteRefusal } from "./suite-gate.js";
25
26
  /** What the suite gate names when a task route refuses a plan without it. */ const TASKS_ACT = "Working a record's tasks";
@@ -61,6 +62,7 @@ const ORG_REFUSAL = 'Editing tasks at the organization level requires the "Manag
61
62
  ok: true,
62
63
  uid: caller.uid,
63
64
  email: (_caller_email = caller.email) != null ? _caller_email : null,
65
+ name: caller.name,
64
66
  staff: caller.staff,
65
67
  orgId: caller.orgId,
66
68
  org: caller.org,
@@ -107,6 +109,7 @@ const ORG_REFUSAL = 'Editing tasks at the organization level requires the "Manag
107
109
  ok: true,
108
110
  uid: decoded.uid,
109
111
  email: typeof decoded.email === 'string' ? decoded.email : null,
112
+ name: typeof decoded['name'] === 'string' ? decoded['name'].trim().slice(0, 120) : '',
110
113
  staff,
111
114
  orgId,
112
115
  org: org != null ? org : {},
@@ -241,17 +244,33 @@ const tasksCollection = (orgId)=>firebaseAdmin.app().firestore().collection('org
241
244
  return answer;
242
245
  };
243
246
  }
247
+ /**
248
+ * What an assignee is told: who handed them the task, in which workspace,
249
+ * and when it is due. It is mailed to anyone who turned that email on, and
250
+ * reads whole without its "Task assigned to you" title.
251
+ *
252
+ * The due time is printed in the zone the reminders and the digest use, with
253
+ * the zone named. A bare `toLocaleString()` here would print the server's
254
+ * UTC with no zone, five hours off a Chicago reader's clock and silent
255
+ * about it.
256
+ */ function taskAssignedBody(writer, fields, timeZone = digestTimeZone()) {
257
+ var _ref, _writer_name, _ref1, _writer_email, _writer_org_name, _writer_org_slug;
258
+ var _writer_member, _writer_member1;
259
+ const who = String((_ref = (_writer_member = writer.member) == null ? void 0 : _writer_member.displayName) != null ? _ref : '').trim() || String((_writer_name = writer.name) != null ? _writer_name : '').trim() || String((_ref1 = (_writer_member1 = writer.member) == null ? void 0 : _writer_member1.email) != null ? _ref1 : '').trim() || String((_writer_email = writer.email) != null ? _writer_email : '').trim() || 'Someone';
260
+ const workspace = String((_writer_org_name = writer.org['name']) != null ? _writer_org_name : '').trim() || String((_writer_org_slug = writer.org['slug']) != null ? _writer_org_slug : '').trim();
261
+ const due = typeof fields.dueAtMs === 'number' ? `, due ${crmTaskReminderWhen(fields.dueAtMs, timeZone)}` : '';
262
+ return `${who} assigned you a task${workspace ? ` in ${workspace}` : ''}: ${fields.title}${due}.`;
263
+ }
244
264
  /** Tell the assignee, when they are somebody new and somebody else. */ async function notifyAssignee(writer, fields, previousAssignee, hostId) {
245
265
  const assignee = fields.assigneeUid;
246
266
  const notified = Boolean(assignee && assignee !== writer.uid && assignee !== previousAssignee);
247
267
  if (!notified || !assignee) return false;
248
- const due = typeof fields.dueAtMs === 'number' ? ` · due ${new Date(fields.dueAtMs).toLocaleString()}` : '';
249
268
  await notifyUsers([
250
269
  assignee
251
270
  ], _extends({
252
271
  type: 'content.taskAssigned',
253
272
  title: 'Task assigned to you',
254
- body: `${fields.title}${due}`,
273
+ body: taskAssignedBody(writer, fields),
255
274
  link: assignmentLink(hostId, fields),
256
275
  orgId: writer.orgId
257
276
  }, hostId ? {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-routes.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 consentGroupForHost,\n CRM_COLLECTIONS,\n type CrmTask,\n crmScopeTokens,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n crmTaskReminderPending,\n isOrgWideMember,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n} from '@aglyn/aglyn/server'\nimport {\n crmNextActivityLinksOf,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n notifyUsers,\n recomputeCrmNextTaskAt,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_TASK_BATCH_MAX,\n type CrmTaskCompleteOutcome,\n type CrmTaskCompleteResponse,\n type CrmTaskFields,\n type CrmTaskSaveOutcome,\n type CrmTaskSaveResponse,\n type CrmTasksCompleteResponse,\n type CrmTasksSaveResponse,\n readCrmTaskFields,\n} from '../model/task-routes'\nimport { CRM_ORG_TASK_SCOPE } from '../model/task-scope'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * The two things a task does that a browser must not do alone (AGL-2599).\n *\n * Every other task write is client-direct against the Firestore rules —\n * reopening, deleting, the list's own reads. These two are routes because\n * each has a side effect that lives outside the document:\n *\n * - SAVING a task assigned to somebody else NOTIFIES them, and a\n * notification is an Admin-SDK write into another person's inbox. Rather\n * than a client-direct write followed by a \"please notify\" call — two\n * operations that can disagree about whether the task exists — the route\n * persists the task itself and notifies from the same code path, so a\n * notification can never announce a task that failed to save. It also\n * stamps the scope, the provenance and the timestamps, which is one\n * creator computing `visibleTo` rather than two.\n *\n * - COMPLETING a task EMITS the `taskCompleted` host event, which is what a\n * workflow triggers on. A browser cannot run the workflow runner.\n *\n * Both authenticate the way the other console plugin routes do — the ID\n * token in the `Authorization` header — and then ask the same two questions\n * the rules ask of a `crmTasks` write: is this member allowed to write org\n * data (`owner`/`admin`/`editor`, and `data.manage` resolved through their\n * custom role), and can they reach THIS document's scope.\n *\n * ## The organization variant (AGL-2637)\n *\n * `{ orgId, … }` instead of a site: the org-level hub. The caller is\n * authorized by the ORG — an org-wide member holding `data.manage`, the\n * hub's own gate, never a site collaborator — and reaches every task the\n * org holds, so no scope check is made against the document. A NEW task\n * names the site it is filed from beside the org, or names none and is the\n * organization's own: `hostId: null`, the org scope token alone. Completing\n * emits on the task's OWN site, where its automations live; an\n * organization task has none and emits nothing, as an uncaptured deal's\n * stage change emits nothing.\n *\n * The org variant also takes a BATCH — `taskIds` to complete, `tasks` to\n * save — so the org hub's bulk bar makes one request per action rather\n * than one per task. Each task is answered on its own in `results`; the\n * request as a whole is refused only for what refuses every task alike.\n */\n\nexport type Refusal = {\n ok: false\n status: number\n body: { error: string; reason?: string; code?: string }\n}\n\n/** What the suite gate names when a task route refuses a plan without it. */\nconst TASKS_ACT = \"Working a record's tasks\"\n\nexport interface Writer {\n ok: true\n uid: string\n /** The caller's address, for who set off the runs a write causes (AGL-3376). */\n email: string | null\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n /** The member document under a site; `null` at the org level, where the reach is the org's. */\n member: Partial<AglynOrgMember> | null\n level: CrmRouteScope['level']\n}\n\nexport const refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n body: { error },\n})\n\nconst ORG_REFUSAL =\n 'Editing tasks at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.'\n\nexport interface CrmWriterOptions {\n /**\n * The act a plan without the CRM is refused for, named the way\n * `crmSuiteRefusalMessage` takes it. `null` for a route that asks the plan\n * itself, once it knows what the request writes, so the refusal can name\n * that act — a contact update names the first field it carries. Tasks\n * when unsaid.\n */\n suiteAct?: string | null\n}\n\n/**\n * Who is asking, and whether they may write this CRM at all.\n *\n * Under a site: the org resolved from the site and the rules' own role and\n * permission questions. At the organization level: the org the body names,\n * for an org-wide member with the permission. The scope question — may they\n * reach THIS task — is asked by the caller against the document, because on\n * a create the document does not exist yet and its scope is what the route\n * is about to stamp.\n */\nexport async function authorizeCrmWriter(\n req: PluginApiRequest,\n scope: CrmRouteScope,\n options: CrmWriterOptions = {},\n): Promise<Writer | Refusal> {\n const suiteAct = options.suiteAct === undefined ? TASKS_ACT : options.suiteAct\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: ORG_REFUSAL,\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n // The plan after the person (AGL-2787): see `suite-gate.ts`.\n const suite = suiteAct ? crmSuiteRefusal(caller.org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email ?? null,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org as Record<string, unknown>,\n member: null,\n level: 'org',\n }\n }\n\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\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const resolved = await getOrgForHost(scope.hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const role = String(member?.role ?? '')\n /*\n * The rules' `canWriteOrgData()` restated: the role list is what an\n * unstamped permission map falls through to, and the permission is the\n * only half a custom role's revocation can reach. A route that checked\n * one and not the other would open a door the rules keep shut.\n */\n const writerRole = role === 'owner' || role === 'admin' || role === 'editor'\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || !writerRole || suspended) {\n return refuse(403, 'Your organization role does not allow editing the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(\n 403,\n 'Your organization role does not allow editing the CRM.',\n )\n }\n }\n const suite = suiteAct ? crmSuiteRefusal(org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n level: 'site',\n }\n}\n\n/**\n * The rules' `canReadScoped()`, for a document the route has in hand. An\n * org-level writer was admitted as org-wide, and an org-wide member reads\n * every row.\n */\nexport const canReach = (writer: Writer, visibleTo: unknown): boolean =>\n writer.staff ||\n writer.level === 'org' ||\n isOrgWideMember(writer.member) ||\n memberCanSee(writer.member, visibleTo as string[] | undefined)\n\n/**\n * The records the task names carry `nextTaskAtMs` (AGL-2661), recomputed\n * after every write here. Its own catch: the task IS saved by now, and a\n * denormalized figure that could not move is logged and left for the\n * Fields section's recompute, not reported as a task that failed to save.\n */\nasync function settleNextActivity(\n orgId: string,\n links: readonly (Partial<CrmTaskFields> | Record<string, unknown> | null | undefined)[],\n): Promise<void> {\n try {\n await recomputeCrmNextTaskAt(\n firebaseAdmin.app().firestore(),\n orgId,\n links.map((link) => crmNextActivityLinksOf(link as Record<string, unknown>)),\n )\n } catch (error) {\n console.error('[crm] next activity could not be recomputed', orgId, error)\n }\n}\n\n/**\n * The console path the assignee's notification opens: the record the task\n * is for, in the hub, or the tasks list when it is for nobody in particular.\n *\n * In the `/{hostDocId}/rest` shape every host notification uses — the\n * console rewrites it to `/{orgSlug}/hosts/{subdomain}/rest` when the link\n * is followed, which is why `hostId` and `orgId` travel on the document. An\n * organization task has no site to open under, so its link is the `/org`\n * shape the console rewrites onto the organization's own hub.\n */\nfunction assignmentLink(hostId: string | null, fields: CrmTaskFields): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (fields.contactId) return `${base}/contacts/${encodeURIComponent(fields.contactId)}`\n if (fields.dealId) return `${base}/deals/${encodeURIComponent(fields.dealId)}`\n if (fields.companyId) return `${base}/companies/${encodeURIComponent(fields.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * The editable fields as a Firestore write.\n *\n * The optional ids and the assignee are ABSENT on the document when unset,\n * never `null` — the record cards query `where('contactId', '==', id)` and\n * the \"My tasks\" view `where('assigneeUid', '==', uid)`, and neither is\n * helped by a null. So a create leaves an unset optional out, and an update\n * DELETES it: an assignee cleared in the drawer has to come off the\n * document, and an update that merely omitted the key would keep them.\n * `dueAtMs` is the one field written as `null` when unset, because every\n * view orders by it and a document missing the field drops out of an\n * `orderBy` entirely.\n */\nfunction storedFields(\n fields: CrmTaskFields,\n mode: 'create' | 'update',\n): Record<string, unknown> {\n const absent = mode === 'update' ? FieldValue.delete() : undefined\n const optional = (value: string | null) =>\n value ? { present: true, value } : { present: mode === 'update', value: absent }\n const out: Record<string, unknown> = {\n title: fields.title,\n kind: fields.kind,\n priority: fields.priority,\n dueAtMs: fields.dueAtMs,\n notes: fields.notes,\n }\n for (const [key, value] of [\n ['assigneeUid', fields.assigneeUid],\n ['contactId', fields.contactId],\n ['companyId', fields.companyId],\n ['dealId', fields.dealId],\n ] as const) {\n const slot = optional(value)\n if (slot.present) out[key] = slot.value\n }\n return out\n}\n\n/**\n * The reminder a save leaves (AGL-2659), as a Firestore write.\n *\n * `crmTaskReminderAfterEdit` decides which reminder the task ends up with;\n * this turns the decision into fields. Written as `null` when unset, like\n * `dueAtMs`, so the document says the reminder was decided. A reminder that\n * MOVED — to a new time, or to none — is a reminder not yet sent, so the\n * runner's mark comes off with it; one that stayed keeps its mark, which is\n * what stops a title correction from re-sending last week's reminder.\n */\nfunction reminderFields(\n fields: CrmTaskFields,\n existing: FirebaseFirestore.DocumentSnapshot | null,\n): Record<string, unknown> {\n const previous = existing\n ? {\n dueAtMs: (existing.get('dueAtMs') as number | null | undefined) ?? null,\n remindAtMs: (existing.get('remindAtMs') as number | null | undefined) ?? null,\n }\n : null\n const remindAtMs = crmTaskReminderAfterEdit({\n dueAtMs: fields.dueAtMs,\n remindAtMs: fields.remindAtMs,\n previous,\n })\n const out: Record<string, unknown> = { remindAtMs }\n if (previous && remindAtMs !== previous.remindAtMs) {\n out['reminderSentAtMs'] = FieldValue.delete()\n }\n return out\n}\n\nconst tasksCollection = (orgId: string) =>\n firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.tasks)\n\ntype TasksCollection = ReturnType<typeof tasksCollection>\n\n/**\n * Whether a uid names a member of the org, answered once per uid across a\n * batch: a bar reassigning forty tasks to one person asks the roster once.\n */\nfunction rosterOf(orgId: string) {\n const known = new Map<string, Promise<boolean>>()\n return (uid: string): Promise<boolean> => {\n let answer = known.get(uid)\n if (!answer) {\n answer = resolveOrgMembership(uid, orgId)\n .then((found) => Boolean(found?.member))\n .catch(() => false)\n known.set(uid, answer)\n }\n return answer\n }\n}\n\n/** Tell the assignee, when they are somebody new and somebody else. */\nasync function notifyAssignee(\n writer: Writer,\n fields: CrmTaskFields,\n previousAssignee: string | null,\n hostId: string | null,\n): Promise<boolean> {\n const assignee = fields.assigneeUid\n const notified = Boolean(\n assignee && assignee !== writer.uid && assignee !== previousAssignee,\n )\n if (!notified || !assignee) return false\n const due =\n typeof fields.dueAtMs === 'number'\n ? ` · due ${new Date(fields.dueAtMs).toLocaleString()}`\n : ''\n await notifyUsers([assignee], {\n type: 'content.taskAssigned',\n title: 'Task assigned to you',\n body: `${fields.title}${due}`,\n link: assignmentLink(hostId, fields),\n orgId: writer.orgId,\n ...(hostId ? { hostId } : {}),\n })\n return true\n}\n\n/**\n * Rewrite one existing task's editable fields, and tell a new assignee.\n *\n * `visibleTo` is not touched: the rules let only an org-wide member move a\n * record's scope, and this route is not the place that does it. `hostId`\n * and `createdByUid` are provenance and never rewritten. The notification\n * opens under the site the caller is on, or — at the organization level —\n * under the task's own site, and under the org hub for a task with none.\n */\nasync function updateTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const existing = await tasks.doc(taskId).get()\n if (!existing.exists) return refuse(404, 'That task no longer exists.')\n if (!canReach(writer, existing.get('visibleTo'))) {\n return refuse(403, 'That task is not visible to you.')\n }\n const previousAssignee = String(existing.get('assigneeUid') ?? '') || null\n const previousLinks = crmNextActivityLinksOf(existing.data() as Record<string, unknown>)\n const stored = storedFields(fields, 'update')\n await tasks.doc(taskId).update({\n ...stored,\n ...reminderFields(fields, existing),\n // The title is what the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...existing.data(), ...stored }),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Both sides: a task moved off one deal onto another leaves neither stale.\n await settleNextActivity(writer.orgId, [previousLinks, fields])\n const linkHostId =\n scope.level === 'site'\n ? scope.hostId\n : String(existing.get('hostId') ?? '').trim() || null\n const notified = await notifyAssignee(writer, fields, previousAssignee, linkHostId)\n return { ok: true, notified }\n}\n\n/**\n * Where a NEW task is filed from: the mounted site, or at the organization\n * level the site the body names — which has to be one of THIS org's, or an\n * org-wide member of one org could stamp a task with another org's site\n * token — or no site at all, the organization's own.\n */\nasync function resolveFiledFrom(\n writer: Writer,\n scope: CrmRouteScope,\n): Promise<{ ok: true; hostId: string | null } | Refusal> {\n if (scope.level === 'site') return { ok: true, hostId: scope.hostId }\n if (!scope.hostId) return { ok: true, hostId: null }\n const owner = await getOrgForHost(scope.hostId).catch(() => null)\n if (!owner || owner.orgId !== writer.orgId) {\n return refuse(400, 'That site is not one of this organization’s.')\n }\n return { ok: true, hostId: scope.hostId }\n}\n\n/**\n * Create one task, stamped with its scope and provenance.\n *\n * The scope every CRM creator stamps for a task filed from a site: the\n * whole org when the org has chosen that, and otherwise the sites this\n * site presents as one sender with — which, undeclared, is this site\n * alone. A scoped member creating a record they could not then see is\n * refused, which is the rules' `canCreateScoped()` said before the write\n * instead of after. An organization task — no site — carries\n * `CRM_ORG_TASK_SCOPE` and `hostId: null`.\n */\nasync function createTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; taskId: string; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const filed = await resolveFiledFrom(writer, scope)\n if (filed.ok === false) return filed\n const { hostId } = filed\n const visibleTo = hostId\n ? crmScopeTokens(writer.org, consentGroupForHost(writer.org, hostId))\n : [...CRM_ORG_TASK_SCOPE]\n if (!canReach(writer, visibleTo)) {\n return refuse(403, 'Your access to this organization does not reach this site.')\n }\n const ref = tasks.doc()\n await ref.set({\n ...storedFields(fields, 'create'),\n ...reminderFields(fields, null),\n // What the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...storedFields(fields, 'create'), visibleTo }),\n status: 'open',\n completedAtMs: null,\n visibleTo,\n hostId,\n createdByUid: writer.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [fields])\n const notified = await notifyAssignee(writer, fields, null, hostId)\n return { ok: true, taskId: ref.id, notified }\n}\n\n/**\n * `POST crm/task-save` — create or update a task, and tell its assignee.\n *\n * Body: `{ hostId, taskId?, task: CrmTaskFields }` under a site;\n * `{ orgId, hostId?, taskId?, task }` at the organization level; or the\n * org-level batch `{ orgId, tasks: [{ taskId, task }] }`. Answers\n * `{ ok, taskId, notified }`, or `{ ok, results }` for the batch.\n *\n * The NOTIFICATION fires when the saved assignee is somebody other than the\n * person saving AND is not who the task was already assigned to. Assigning\n * a task to yourself is a note to self; re-saving a task's title does not\n * re-announce it to the person who already has it. The assignee must be a\n * member of the org — a uid nobody can find in the roster is refused rather\n * than written, because a notification to a stranger's inbox is the one\n * thing this route must never do.\n */\nexport const crmTaskSaveHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n if (Array.isArray(req.body?.tasks)) {\n await saveBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n const read = readCrmTaskFields(req.body?.task)\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const fields = read.fields\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n let body: CrmTaskSaveResponse\n if (taskId) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId, notified: saved.notified }\n } else {\n const saved = await createTask(writer, scope, tasks, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId: saved.taskId, notified: saved.notified }\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save failed', error)\n res.status(500).json({ error: 'The task could not be saved.' })\n }\n}\n\n/**\n * The batch: every entry read before any is written, so a body with one\n * unreadable entry is refused whole rather than half-applied; then each\n * task in order, each answered on its own.\n */\nasync function saveBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope,\n): Promise<void> {\n if (scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is saved at the organization level only.',\n })\n return\n }\n const entries = (req.body?.tasks as unknown[]).slice(0, CRM_TASK_BATCH_MAX + 1)\n if (!entries.length || entries.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Save between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n const parsed: Array<{ taskId: string; fields: CrmTaskFields }> = []\n for (const entry of entries) {\n const raw = (entry ?? {}) as Record<string, unknown>\n const taskId = String(raw['taskId'] ?? '').trim()\n const read = readCrmTaskFields(raw['task'])\n if (!taskId || read.ok === false) {\n res.status(400).json({\n error: read.ok === false ? read.error : 'Every task in a batch names its taskId.',\n })\n return\n }\n parsed.push({ taskId, fields: read.fields })\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n const results: CrmTaskSaveOutcome[] = []\n for (const { taskId, fields } of parsed) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n results.push(\n saved.ok === false\n ? { taskId, ok: false, error: saved.body.error }\n : { taskId, ok: true, notified: saved.notified },\n )\n }\n const body: CrmTasksSaveResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save batch failed', error)\n res.status(500).json({ error: 'The tasks could not be saved.' })\n }\n}\n\n/**\n * Mark one task done and fire `taskCompleted` on the site whose automations\n * hear it: under a site, the site whose console ticked the box — workflows\n * belong to a site, and the person is on that site's console. At the\n * organization level there is no such console, so the event goes to the\n * task's OWN site, and an organization task, having none, fires nothing.\n * The task's own `hostId` rides in the payload as `taskHostId` either way,\n * for a workflow that cares about the difference.\n *\n * Idempotent on purpose: two people ticking the same box, or one person\n * double-clicking, completes a task once and fires its event once. The\n * second call reads the stored `completedAtMs` back and writes nothing.\n */\nasync function completeTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n): Promise<CrmTaskCompleteOutcome> {\n const ref = tasks.doc(taskId)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { taskId, ok: false, error: 'That task no longer exists.' }\n }\n const task = snapshot.data() as Partial<CrmTask>\n if (!canReach(writer, task.visibleTo)) {\n return { taskId, ok: false, error: 'That task is not visible to you.' }\n }\n if (task.status === 'done') {\n return {\n taskId,\n ok: true,\n completedAtMs: Number(task.completedAtMs ?? 0),\n alreadyDone: true,\n }\n }\n\n const completedAtMs = Date.now()\n await ref.update({\n status: 'done',\n completedAtMs,\n completedByUid: writer.uid,\n // A reminder still owed on a task that is done is owed to nobody\n // (AGL-2659); one already sent stays, as a fact about what happened.\n ...(crmTaskReminderPending(task) ? { remindAtMs: null } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [task as Record<string, unknown>])\n\n const eventHostId =\n scope.level === 'site' ? scope.hostId : String(task.hostId ?? '').trim()\n if (eventHostId) {\n /*\n * The payload is what a workflow filter can read: every field a string,\n * number or boolean, and every optional one present as an empty string\n * rather than absent, so `contactId != \"\"` is a filter somebody can\n * write without knowing whether the key exists.\n */\n await emitHostEvent(\n eventHostId,\n 'taskCompleted',\n {\n taskId,\n title: String(task.title ?? ''),\n kind: String(task.kind ?? ''),\n priority: String(task.priority ?? ''),\n dueAtMs: typeof task.dueAtMs === 'number' ? task.dueAtMs : 0,\n completedAtMs,\n completedByUid: writer.uid,\n assigneeUid: String(task.assigneeUid ?? ''),\n createdByUid: String(task.createdByUid ?? ''),\n contactId: String(task.contactId ?? ''),\n companyId: String(task.companyId ?? ''),\n dealId: String(task.dealId ?? ''),\n taskHostId: String(task.hostId ?? ''),\n },\n { actor: { kind: 'member', uid: writer.uid, email: writer.email } },\n )\n }\n return { taskId, ok: true, completedAtMs }\n}\n\n/** A per-task outcome as the single-task route has always answered. */\nconst completeStatus = (outcome: Extract<CrmTaskCompleteOutcome, { ok: false }>) =>\n outcome.error === 'That task no longer exists.' ? 404 : 403\n\n/**\n * `POST crm/task-complete` — mark a task done and fire `taskCompleted`.\n *\n * Body: `{ hostId, taskId }` under a site, `{ orgId, taskId }` at the\n * organization level, or the org-level batch `{ orgId, taskIds }`. Answers\n * `{ ok, completedAtMs, alreadyDone? }`, or `{ ok, results }` for the batch.\n */\nexport const crmTaskCompleteHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (Array.isArray(req.body?.taskIds)) {\n await completeBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n if (!scope || !taskId) {\n res.status(400).json({ error: 'Missing hostId or taskId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const outcome = await completeTask(writer, scope, tasksCollection(writer.orgId), taskId)\n if (outcome.ok === false) {\n res.status(completeStatus(outcome)).json({ error: outcome.error })\n return\n }\n const body: CrmTaskCompleteResponse = {\n ok: true,\n completedAtMs: outcome.completedAtMs,\n ...(outcome.alreadyDone ? { alreadyDone: true } : {}),\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete failed', error)\n res.status(500).json({ error: 'The task could not be completed.' })\n }\n}\n\n/**\n * The batch: the ids deduplicated and capped, then each task in order —\n * in order rather than at once, because each completion may run a site's\n * workflows, and two hundred runners started together is the burst the\n * per-task client call was written to avoid.\n */\nasync function completeBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope | null,\n): Promise<void> {\n if (!scope || scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is completed at the organization level only.',\n })\n return\n }\n const taskIds = [\n ...new Set(\n (req.body?.taskIds as unknown[]).map((id) => String(id ?? '').trim()).filter(Boolean),\n ),\n ]\n if (!taskIds.length || taskIds.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Complete between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const results: CrmTaskCompleteOutcome[] = []\n for (const taskId of taskIds) {\n results.push(await completeTask(writer, scope, tasks, taskId))\n }\n const body: CrmTasksCompleteResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete batch failed', error)\n res.status(500).json({ error: 'The tasks could not be completed.' })\n }\n}\n"],"names":["consentGroupForHost","CRM_COLLECTIONS","crmScopeTokens","crmTaskListFields","crmTaskReminderAfterEdit","crmTaskReminderPending","isOrgWideMember","memberCanSee","crmNextActivityLinksOf","firebaseAdmin","getOrgForHost","memberHasOrgPermission","notifyUsers","recomputeCrmNextTaskAt","resolveOrgMembership","emitHostEvent","FieldValue","CRM_TASK_BATCH_MAX","readCrmTaskFields","CRM_ORG_TASK_SCOPE","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","TASKS_ACT","refuse","status","error","ok","body","ORG_REFUSAL","authorizeCrmWriter","req","scope","options","suiteAct","undefined","level","caller","orgId","needs","refusal","suite","org","uid","email","staff","member","authorization","String","headers","idToken","startsWith","slice","length","decoded","app","auth","verifyIdToken","resolved","hostId","catch","membership","role","writerRole","suspended","orgSuspended","canReach","writer","visibleTo","settleNextActivity","links","firestore","map","link","console","assignmentLink","fields","base","contactId","encodeURIComponent","dealId","companyId","storedFields","mode","absent","delete","optional","value","present","out","title","kind","priority","dueAtMs","notes","key","assigneeUid","slot","reminderFields","existing","previous","get","remindAtMs","tasksCollection","collection","doc","tasks","rosterOf","known","Map","answer","then","found","Boolean","set","notifyAssignee","previousAssignee","assignee","notified","due","Date","toLocaleString","type","updateTask","taskId","onRoster","exists","previousLinks","data","stored","update","updatedAt","serverTimestamp","linkHostId","trim","resolveFiledFrom","owner","createTask","filed","ref","completedAtMs","createdByUid","createdAt","id","crmTaskSaveHandler","res","method","setHeader","json","Array","isArray","saveBatch","read","task","saved","entries","parsed","entry","raw","push","results","completeTask","snapshot","Number","alreadyDone","now","completedByUid","eventHostId","taskHostId","actor","completeStatus","outcome","crmTaskCompleteHandler","taskIds","completeBatch","Set","filter"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,mBAAmB,EACnBC,eAAe,EAEfC,cAAc,EACdC,iBAAiB,EACjBC,wBAAwB,EACxBC,sBAAsB,EACtBC,eAAe,EACfC,YAAY,QAIP,sBAAqB;AAC5B,SACEC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,sBAAsB,EACtBC,WAAW,EACXC,sBAAsB,EACtBC,oBAAoB,QACf,2BAA0B;AACjC,SAASC,aAAa,QAAQ,wBAAuB;AACrD,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,kBAAkB,EAQlBC,iBAAiB,QACZ,0BAAsB;AAC7B,SAASC,kBAAkB,QAAQ,yBAAqB;AACxD,SACEC,kBAAkB,EAElBC,iBAAiB,QACZ,kBAAc;AACrB,SAASC,eAAe,QAAQ,kBAAc;AAmD9C,2EAA2E,GAC3E,MAAMC,YAAY;AAelB,OAAO,MAAMC,SAAS,CAACC,QAAgBC,QAA4B,CAAA;QACjEC,IAAI;QACJF;QACAG,MAAM;YAAEF;QAAM;IAChB,CAAA,EAAE;AAEF,MAAMG,cACJ,wEACA;AAaF;;;;;;;;;CASC,GACD,OAAO,eAAeC,mBACpBC,GAAqB,EACrBC,KAAoB,EACpBC,UAA4B,CAAC,CAAC;QAwBDF;IAtB7B,MAAMG,WAAWD,QAAQC,QAAQ,KAAKC,YAAYZ,YAAYU,QAAQC,QAAQ;IAC9E,IAAIF,MAAMI,KAAK,KAAK,OAAO;YAYhBC;QAXT,MAAMA,SAAS,MAAMjB,mBAAmBW,KAAKC,MAAMM,KAAK,EAAE;YACxDC,OAAO;YACPC,SAASX;QACX;QACA,IAAIQ,OAAOV,EAAE,KAAK,OAAO,OAAOH,OAAOa,OAAOZ,MAAM,EAAEY,OAAOX,KAAK;QAClE,6DAA6D;QAC7D,MAAMe,QAAQP,WAAWZ,gBAAgBe,OAAOK,GAAG,EAAER,YAAY;QACjE,IAAIO,OAAO,OAAO;YAAEd,IAAI;YAAOF,QAAQgB,MAAMhB,MAAM;YAAEG,MAAMa,MAAMb,IAAI;QAAC;QACtE,OAAO;YACLD,IAAI;YACJgB,KAAKN,OAAOM,GAAG;YACfC,KAAK,GAAEP,gBAAAA,OAAOO,KAAK,YAAZP,gBAAgB;YACvBQ,OAAOR,OAAOQ,KAAK;YACnBP,OAAOD,OAAOC,KAAK;YACnBI,KAAKL,OAAOK,GAAG;YACfI,QAAQ;YACRV,OAAO;QACT;IACF;IAEA,MAAMW,gBAAgBC,QAAOjB,6BAAAA,IAAIkB,OAAO,CAACF,aAAa,YAAzBhB,6BAA6B;IAC1D,MAAMmB,UAAUH,cAAcI,UAAU,CAAC,aACrCJ,cAAcK,KAAK,CAAC,UAAUC,MAAM,IACpClB;IACJ,IAAI,CAACe,SAAS,OAAO1B,OAAO,KAAK;IAEjC,MAAM8B,UAAU,MAAM7C,cAAc8C,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACP;IAC/D,MAAML,QAAQS,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMI,WAAW,MAAMhD,cAAcsB,MAAM2B,MAAM,EAAEC,KAAK,CAAC,IAAM;IAC/D,IAAI,CAACF,UAAU;QACb,OAAOlC,OAAO,KAAK;IACrB;IACA,MAAM,EAAEc,KAAK,EAAEI,GAAG,EAAE,GAAGgB;IACvB,MAAMG,aAAa,MAAM/C,qBAAqBwC,QAAQX,GAAG,EAAEL,OAAOsB,KAAK,CACrE,IAAM;IAER,MAAMd,iBAAUe,8BAAAA,WAAYf,MAAM,mBAAI;IACtC,IAAI,CAACD,OAAO;;QACV,MAAMiB,OAAOd,gBAAOF,0BAAAA,OAAQgB,IAAI,oBAAI;QACpC;;;;;KAKC,GACD,MAAMC,aAAaD,SAAS,WAAWA,SAAS,WAAWA,SAAS;QACpE,MAAME,YACJ,CAAClB,0BAAD,AAACA,OAA8CmB,YAAY,MAAK;QAClE,IAAI,CAACnB,UAAU,CAACiB,cAAcC,WAAW;YACvC,OAAOxC,OAAO,KAAK;QACrB;QACA,IAAI,CAAE,MAAMb,uBAAuB2B,OAAOQ,QAAQ,gBAAiB;YACjE,OAAOtB,OACL,KACA;QAEJ;IACF;IACA,MAAMiB,QAAQP,WAAWZ,gBAAgBoB,KAAKR,YAAY;IAC1D,IAAIO,OAAO,OAAO;QAAEd,IAAI;QAAOF,QAAQgB,MAAMhB,MAAM;QAAEG,MAAMa,MAAMb,IAAI;IAAC;IACtE,OAAO;QACLD,IAAI;QACJgB,KAAKW,QAAQX,GAAG;QAChBC,OAAO,OAAOU,QAAQV,KAAK,KAAK,WAAWU,QAAQV,KAAK,GAAG;QAC3DC;QACAP;QACAI,GAAG,EAAGA,cAAAA,MAAO,CAAC;QACdI;QACAV,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAM8B,WAAW,CAACC,QAAgBC,YACvCD,OAAOtB,KAAK,IACZsB,OAAO/B,KAAK,KAAK,SACjB9B,gBAAgB6D,OAAOrB,MAAM,KAC7BvC,aAAa4D,OAAOrB,MAAM,EAAEsB,WAAkC;AAEhE;;;;;CAKC,GACD,eAAeC,mBACb/B,KAAa,EACbgC,KAAuF;IAEvF,IAAI;QACF,MAAMzD,uBACJJ,cAAc8C,GAAG,GAAGgB,SAAS,IAC7BjC,OACAgC,MAAME,GAAG,CAAC,CAACC,OAASjE,uBAAuBiE;IAE/C,EAAE,OAAO/C,OAAO;QACdgD,QAAQhD,KAAK,CAAC,+CAA+CY,OAAOZ;IACtE;AACF;AAEA;;;;;;;;;CASC,GACD,SAASiD,eAAehB,MAAqB,EAAEiB,MAAqB;IAClE,MAAMC,OAAOlB,SAAS,CAAC,CAAC,EAAEA,OAAO,IAAI,CAAC,GAAG;IACzC,IAAIiB,OAAOE,SAAS,EAAE,OAAO,GAAGD,KAAK,UAAU,EAAEE,mBAAmBH,OAAOE,SAAS,GAAG;IACvF,IAAIF,OAAOI,MAAM,EAAE,OAAO,GAAGH,KAAK,OAAO,EAAEE,mBAAmBH,OAAOI,MAAM,GAAG;IAC9E,IAAIJ,OAAOK,SAAS,EAAE,OAAO,GAAGJ,KAAK,WAAW,EAAEE,mBAAmBH,OAAOK,SAAS,GAAG;IACxF,OAAO,GAAGJ,KAAK,MAAM,CAAC;AACxB;AAEA;;;;;;;;;;;;CAYC,GACD,SAASK,aACPN,MAAqB,EACrBO,IAAyB;IAEzB,MAAMC,SAASD,SAAS,WAAWnE,WAAWqE,MAAM,KAAKlD;IACzD,MAAMmD,WAAW,CAACC,QAChBA,QAAQ;YAAEC,SAAS;YAAMD;QAAM,IAAI;YAAEC,SAASL,SAAS;YAAUI,OAAOH;QAAO;IACjF,MAAMK,MAA+B;QACnCC,OAAOd,OAAOc,KAAK;QACnBC,MAAMf,OAAOe,IAAI;QACjBC,UAAUhB,OAAOgB,QAAQ;QACzBC,SAASjB,OAAOiB,OAAO;QACvBC,OAAOlB,OAAOkB,KAAK;IACrB;IACA,KAAK,MAAM,CAACC,KAAKR,MAAM,IAAI;QACzB;YAAC;YAAeX,OAAOoB,WAAW;SAAC;QACnC;YAAC;YAAapB,OAAOE,SAAS;SAAC;QAC/B;YAAC;YAAaF,OAAOK,SAAS;SAAC;QAC/B;YAAC;YAAUL,OAAOI,MAAM;SAAC;KAC1B,CAAW;QACV,MAAMiB,OAAOX,SAASC;QACtB,IAAIU,KAAKT,OAAO,EAAEC,GAAG,CAACM,IAAI,GAAGE,KAAKV,KAAK;IACzC;IACA,OAAOE;AACT;AAEA;;;;;;;;;CASC,GACD,SAASS,eACPtB,MAAqB,EACrBuB,QAAmD;QAInCA,eACGA;IAHnB,MAAMC,WAAWD,WACb;QACEN,OAAO,GAAGM,gBAAAA,SAASE,GAAG,CAAC,sBAAbF,gBAAyD;QACnEG,UAAU,GAAGH,iBAAAA,SAASE,GAAG,CAAC,yBAAbF,iBAA4D;IAC3E,IACA;IACJ,MAAMG,aAAalG,yBAAyB;QAC1CyF,SAASjB,OAAOiB,OAAO;QACvBS,YAAY1B,OAAO0B,UAAU;QAC7BF;IACF;IACA,MAAMX,MAA+B;QAAEa;IAAW;IAClD,IAAIF,YAAYE,eAAeF,SAASE,UAAU,EAAE;QAClDb,GAAG,CAAC,mBAAmB,GAAGzE,WAAWqE,MAAM;IAC7C;IACA,OAAOI;AACT;AAEA,MAAMc,kBAAkB,CAACjE,QACvB7B,cACG8C,GAAG,GACHgB,SAAS,GACTiC,UAAU,CAAC,QACXC,GAAG,CAACnE,OACJkE,UAAU,CAACvG,gBAAgByG,KAAK;AAIrC;;;CAGC,GACD,SAASC,SAASrE,KAAa;IAC7B,MAAMsE,QAAQ,IAAIC;IAClB,OAAO,CAAClE;QACN,IAAImE,SAASF,MAAMP,GAAG,CAAC1D;QACvB,IAAI,CAACmE,QAAQ;YACXA,SAAShG,qBAAqB6B,KAAKL,OAChCyE,IAAI,CAAC,CAACC,QAAUC,QAAQD,yBAAAA,MAAOlE,MAAM,GACrCc,KAAK,CAAC,IAAM;YACfgD,MAAMM,GAAG,CAACvE,KAAKmE;QACjB;QACA,OAAOA;IACT;AACF;AAEA,qEAAqE,GACrE,eAAeK,eACbhD,MAAc,EACdS,MAAqB,EACrBwC,gBAA+B,EAC/BzD,MAAqB;IAErB,MAAM0D,WAAWzC,OAAOoB,WAAW;IACnC,MAAMsB,WAAWL,QACfI,YAAYA,aAAalD,OAAOxB,GAAG,IAAI0E,aAAaD;IAEtD,IAAI,CAACE,YAAY,CAACD,UAAU,OAAO;IACnC,MAAME,MACJ,OAAO3C,OAAOiB,OAAO,KAAK,WACtB,CAAC,OAAO,EAAE,IAAI2B,KAAK5C,OAAOiB,OAAO,EAAE4B,cAAc,IAAI,GACrD;IACN,MAAM7G,YAAY;QAACyG;KAAS,EAAE;QAC5BK,MAAM;QACNhC,OAAO;QACP9D,MAAM,GAAGgD,OAAOc,KAAK,GAAG6B,KAAK;QAC7B9C,MAAME,eAAehB,QAAQiB;QAC7BtC,OAAO6B,OAAO7B,KAAK;OACfqB,SAAS;QAAEA;IAAO,IAAI,CAAC;IAE7B,OAAO;AACT;AAEA;;;;;;;;CAQC,GACD,eAAegE,WACbxD,MAAc,EACdnC,KAAoB,EACpB0E,KAAsB,EACtBkB,MAAc,EACdhD,MAAqB,EACrBiD,QAA2C;QAUX1B,eAenBA;IAvBb,IAAIvB,OAAOoB,WAAW,IAAI,CAAE,MAAM6B,SAASjD,OAAOoB,WAAW,GAAI;QAC/D,OAAOxE,OAAO,KAAK;IACrB;IACA,MAAM2E,WAAW,MAAMO,MAAMD,GAAG,CAACmB,QAAQvB,GAAG;IAC5C,IAAI,CAACF,SAAS2B,MAAM,EAAE,OAAOtG,OAAO,KAAK;IACzC,IAAI,CAAC0C,SAASC,QAAQgC,SAASE,GAAG,CAAC,eAAe;QAChD,OAAO7E,OAAO,KAAK;IACrB;IACA,MAAM4F,mBAAmBpE,QAAOmD,gBAAAA,SAASE,GAAG,CAAC,0BAAbF,gBAA+B,OAAO;IACtE,MAAM4B,gBAAgBvH,uBAAuB2F,SAAS6B,IAAI;IAC1D,MAAMC,SAAS/C,aAAaN,QAAQ;IACpC,MAAM8B,MAAMD,GAAG,CAACmB,QAAQM,MAAM,CAAC,aAC1BD,QACA/B,eAAetB,QAAQuB,WAEvBhG,kBAAkB,aAAKgG,SAAS6B,IAAI,IAAOC;QAC9CE,WAAWnH,WAAWoH,eAAe;;IAEvC,2EAA2E;IAC3E,MAAM/D,mBAAmBF,OAAO7B,KAAK,EAAE;QAACyF;QAAenD;KAAO;IAC9D,MAAMyD,aACJrG,MAAMI,KAAK,KAAK,SACZJ,MAAM2B,MAAM,GACZX,QAAOmD,iBAAAA,SAASE,GAAG,CAAC,qBAAbF,iBAA0B,IAAImC,IAAI,MAAM;IACrD,MAAMhB,WAAW,MAAMH,eAAehD,QAAQS,QAAQwC,kBAAkBiB;IACxE,OAAO;QAAE1G,IAAI;QAAM2F;IAAS;AAC9B;AAEA;;;;;CAKC,GACD,eAAeiB,iBACbpE,MAAc,EACdnC,KAAoB;IAEpB,IAAIA,MAAMI,KAAK,KAAK,QAAQ,OAAO;QAAET,IAAI;QAAMgC,QAAQ3B,MAAM2B,MAAM;IAAC;IACpE,IAAI,CAAC3B,MAAM2B,MAAM,EAAE,OAAO;QAAEhC,IAAI;QAAMgC,QAAQ;IAAK;IACnD,MAAM6E,QAAQ,MAAM9H,cAAcsB,MAAM2B,MAAM,EAAEC,KAAK,CAAC,IAAM;IAC5D,IAAI,CAAC4E,SAASA,MAAMlG,KAAK,KAAK6B,OAAO7B,KAAK,EAAE;QAC1C,OAAOd,OAAO,KAAK;IACrB;IACA,OAAO;QAAEG,IAAI;QAAMgC,QAAQ3B,MAAM2B,MAAM;IAAC;AAC1C;AAEA;;;;;;;;;;CAUC,GACD,eAAe8E,WACbtE,MAAc,EACdnC,KAAoB,EACpB0E,KAAsB,EACtB9B,MAAqB,EACrBiD,QAA2C;IAE3C,IAAIjD,OAAOoB,WAAW,IAAI,CAAE,MAAM6B,SAASjD,OAAOoB,WAAW,GAAI;QAC/D,OAAOxE,OAAO,KAAK;IACrB;IACA,MAAMkH,QAAQ,MAAMH,iBAAiBpE,QAAQnC;IAC7C,IAAI0G,MAAM/G,EAAE,KAAK,OAAO,OAAO+G;IAC/B,MAAM,EAAE/E,MAAM,EAAE,GAAG+E;IACnB,MAAMtE,YAAYT,SACdzD,eAAeiE,OAAOzB,GAAG,EAAE1C,oBAAoBmE,OAAOzB,GAAG,EAAEiB,WAC3D;WAAIxC;KAAmB;IAC3B,IAAI,CAAC+C,SAASC,QAAQC,YAAY;QAChC,OAAO5C,OAAO,KAAK;IACrB;IACA,MAAMmH,MAAMjC,MAAMD,GAAG;IACrB,MAAMkC,IAAIzB,GAAG,CAAC,aACThC,aAAaN,QAAQ,WACrBsB,eAAetB,QAAQ,OAEvBzE,kBAAkB,aAAK+E,aAAaN,QAAQ;QAAWR;;QAC1D3C,QAAQ;QACRmH,eAAe;QACfxE;QACAT;QACAkF,cAAc1E,OAAOxB,GAAG;QACxBmG,WAAW9H,WAAWoH,eAAe;QACrCD,WAAWnH,WAAWoH,eAAe;;IAEvC,MAAM/D,mBAAmBF,OAAO7B,KAAK,EAAE;QAACsC;KAAO;IAC/C,MAAM0C,WAAW,MAAMH,eAAehD,QAAQS,QAAQ,MAAMjB;IAC5D,OAAO;QAAEhC,IAAI;QAAMiG,QAAQe,IAAII,EAAE;QAAEzB;IAAS;AAC9C;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAM0B,qBAAuC,OAAOjH,KAAKkH;;QAW5ClH,WAIIA,YACSA;IAf/B,IAAIA,IAAImH,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAAqB;QACnD;IACF;IACA,MAAMM,QAAQX,kBAAkBU,IAAIH,IAAI;IACxC,IAAI,CAACI,OAAO;QACViH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAAiB;QAC/C;IACF;IACA,IAAI2H,MAAMC,OAAO,EAACvH,YAAAA,IAAIH,IAAI,qBAARG,UAAU2E,KAAK,GAAG;QAClC,MAAM6C,UAAUxH,KAAKkH,KAAKjH;QAC1B;IACF;IACA,MAAM4F,SAAS5E,gBAAOjB,aAAAA,IAAIH,IAAI,qBAARG,WAAU6F,MAAM,mBAAI,IAAIU,IAAI;IAClD,MAAMkB,OAAOtI,mBAAkBa,aAAAA,IAAIH,IAAI,qBAARG,WAAU0H,IAAI;IAC7C,IAAID,KAAK7H,EAAE,KAAK,OAAO;QACrBsH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO8H,KAAK9H,KAAK;QAAC;QACzC;IACF;IACA,MAAMkD,SAAS4E,KAAK5E,MAAM;IAE1B,IAAI;QACF,MAAMT,SAAS,MAAMrC,mBAAmBC,KAAKC;QAC7C,IAAImC,OAAOxC,EAAE,KAAK,OAAO;YACvBsH,IAAIxH,MAAM,CAAC0C,OAAO1C,MAAM,EAAE2H,IAAI,CAACjF,OAAOvC,IAAI;YAC1C;QACF;QACA,MAAM8E,QAAQH,gBAAgBpC,OAAO7B,KAAK;QAC1C,MAAMuF,WAAWlB,SAASxC,OAAO7B,KAAK;QACtC,IAAIV;QACJ,IAAIgG,QAAQ;YACV,MAAM8B,QAAQ,MAAM/B,WAAWxD,QAAQnC,OAAO0E,OAAOkB,QAAQhD,QAAQiD;YACrE,IAAI6B,MAAM/H,EAAE,KAAK,OAAO;gBACtBsH,IAAIxH,MAAM,CAACiI,MAAMjI,MAAM,EAAE2H,IAAI,CAACM,MAAM9H,IAAI;gBACxC;YACF;YACAA,OAAO;gBAAED,IAAI;gBAAMiG;gBAAQN,UAAUoC,MAAMpC,QAAQ;YAAC;QACtD,OAAO;YACL,MAAMoC,QAAQ,MAAMjB,WAAWtE,QAAQnC,OAAO0E,OAAO9B,QAAQiD;YAC7D,IAAI6B,MAAM/H,EAAE,KAAK,OAAO;gBACtBsH,IAAIxH,MAAM,CAACiI,MAAMjI,MAAM,EAAE2H,IAAI,CAACM,MAAM9H,IAAI;gBACxC;YACF;YACAA,OAAO;gBAAED,IAAI;gBAAMiG,QAAQ8B,MAAM9B,MAAM;gBAAEN,UAAUoC,MAAMpC,QAAQ;YAAC;QACpE;QACA2B,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAACxH;IACvB,EAAE,OAAOF,OAAO;QACdgD,QAAQhD,KAAK,CAAC,0BAA0BA;QACxCuH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAA+B;IAC/D;AACF,EAAC;AAED;;;;CAIC,GACD,eAAe6H,UACbxH,GAAqB,EACrBkH,GAAsB,EACtBjH,KAAoB;QAQHD;IANjB,IAAIC,MAAMI,KAAK,KAAK,OAAO;QACzB6G,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YACnB1H,OAAO;QACT;QACA;IACF;IACA,MAAMiI,UAAU,EAAC5H,YAAAA,IAAIH,IAAI,qBAARG,UAAU2E,KAAK,EAAetD,KAAK,CAAC,GAAGnC,qBAAqB;IAC7E,IAAI,CAAC0I,QAAQtG,MAAM,IAAIsG,QAAQtG,MAAM,GAAGpC,oBAAoB;QAC1DgI,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YACnB1H,OAAO,CAAC,mBAAmB,EAAET,mBAAmB,iBAAiB,CAAC;QACpE;QACA;IACF;IACA,MAAM2I,SAA2D,EAAE;IACnE,KAAK,MAAMC,SAASF,QAAS;YAELG;QADtB,MAAMA,MAAOD,gBAAAA,QAAS,CAAC;QACvB,MAAMjC,SAAS5E,QAAO8G,cAAAA,GAAG,CAAC,SAAS,YAAbA,cAAiB,IAAIxB,IAAI;QAC/C,MAAMkB,OAAOtI,kBAAkB4I,GAAG,CAAC,OAAO;QAC1C,IAAI,CAAClC,UAAU4B,KAAK7H,EAAE,KAAK,OAAO;YAChCsH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;gBACnB1H,OAAO8H,KAAK7H,EAAE,KAAK,QAAQ6H,KAAK9H,KAAK,GAAG;YAC1C;YACA;QACF;QACAkI,OAAOG,IAAI,CAAC;YAAEnC;YAAQhD,QAAQ4E,KAAK5E,MAAM;QAAC;IAC5C;IAEA,IAAI;QACF,MAAMT,SAAS,MAAMrC,mBAAmBC,KAAKC;QAC7C,IAAImC,OAAOxC,EAAE,KAAK,OAAO;YACvBsH,IAAIxH,MAAM,CAAC0C,OAAO1C,MAAM,EAAE2H,IAAI,CAACjF,OAAOvC,IAAI;YAC1C;QACF;QACA,MAAM8E,QAAQH,gBAAgBpC,OAAO7B,KAAK;QAC1C,MAAMuF,WAAWlB,SAASxC,OAAO7B,KAAK;QACtC,MAAM0H,UAAgC,EAAE;QACxC,KAAK,MAAM,EAAEpC,MAAM,EAAEhD,MAAM,EAAE,IAAIgF,OAAQ;YACvC,MAAMF,QAAQ,MAAM/B,WAAWxD,QAAQnC,OAAO0E,OAAOkB,QAAQhD,QAAQiD;YACrEmC,QAAQD,IAAI,CACVL,MAAM/H,EAAE,KAAK,QACT;gBAAEiG;gBAAQjG,IAAI;gBAAOD,OAAOgI,MAAM9H,IAAI,CAACF,KAAK;YAAC,IAC7C;gBAAEkG;gBAAQjG,IAAI;gBAAM2F,UAAUoC,MAAMpC,QAAQ;YAAC;QAErD;QACA,MAAM1F,OAA6B;YAAED,IAAI;YAAMqI;QAAQ;QACvDf,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAACxH;IACvB,EAAE,OAAOF,OAAO;QACdgD,QAAQhD,KAAK,CAAC,gCAAgCA;QAC9CuH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAAgC;IAChE;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,eAAeuI,aACb9F,MAAc,EACdnC,KAAoB,EACpB0E,KAAsB,EACtBkB,MAAc;QAiCmC6B;IA/BjD,MAAMd,MAAMjC,MAAMD,GAAG,CAACmB;IACtB,MAAMsC,WAAW,MAAMvB,IAAItC,GAAG;IAC9B,IAAI,CAAC6D,SAASpC,MAAM,EAAE;QACpB,OAAO;YAAEF;YAAQjG,IAAI;YAAOD,OAAO;QAA8B;IACnE;IACA,MAAM+H,OAAOS,SAASlC,IAAI;IAC1B,IAAI,CAAC9D,SAASC,QAAQsF,KAAKrF,SAAS,GAAG;QACrC,OAAO;YAAEwD;YAAQjG,IAAI;YAAOD,OAAO;QAAmC;IACxE;IACA,IAAI+H,KAAKhI,MAAM,KAAK,QAAQ;YAIFgI;QAHxB,OAAO;YACL7B;YACAjG,IAAI;YACJiH,eAAeuB,QAAOV,sBAAAA,KAAKb,aAAa,YAAlBa,sBAAsB;YAC5CW,aAAa;QACf;IACF;IAEA,MAAMxB,gBAAgBpB,KAAK6C,GAAG;IAC9B,MAAM1B,IAAIT,MAAM,CAAC;QACfzG,QAAQ;QACRmH;QACA0B,gBAAgBnG,OAAOxB,GAAG;OAGtBtC,uBAAuBoJ,QAAQ;QAAEnD,YAAY;IAAK,IAAI,CAAC;QAC3D6B,WAAWnH,WAAWoH,eAAe;;IAEvC,MAAM/D,mBAAmBF,OAAO7B,KAAK,EAAE;QAACmH;KAAgC;IAExE,MAAMc,cACJvI,MAAMI,KAAK,KAAK,SAASJ,MAAM2B,MAAM,GAAGX,QAAOyG,eAAAA,KAAK9F,MAAM,YAAX8F,eAAe,IAAInB,IAAI;IACxE,IAAIiC,aAAa;YAYCd,aACDA,YACIA,gBAIGA,mBACCA,oBACHA,iBACAA,iBACHA,cACIA;QAtBrB;;;;;KAKC,GACD,MAAM1I,cACJwJ,aACA,iBACA;YACA3C;YACAlC,OAAO1C,QAAOyG,cAAAA,KAAK/D,KAAK,YAAV+D,cAAc;YAC5B9D,MAAM3C,QAAOyG,aAAAA,KAAK9D,IAAI,YAAT8D,aAAa;YAC1B7D,UAAU5C,QAAOyG,iBAAAA,KAAK7D,QAAQ,YAAb6D,iBAAiB;YAClC5D,SAAS,OAAO4D,KAAK5D,OAAO,KAAK,WAAW4D,KAAK5D,OAAO,GAAG;YAC3D+C;YACA0B,gBAAgBnG,OAAOxB,GAAG;YAC1BqD,aAAahD,QAAOyG,oBAAAA,KAAKzD,WAAW,YAAhByD,oBAAoB;YACxCZ,cAAc7F,QAAOyG,qBAAAA,KAAKZ,YAAY,YAAjBY,qBAAqB;YAC1C3E,WAAW9B,QAAOyG,kBAAAA,KAAK3E,SAAS,YAAd2E,kBAAkB;YACpCxE,WAAWjC,QAAOyG,kBAAAA,KAAKxE,SAAS,YAAdwE,kBAAkB;YACpCzE,QAAQhC,QAAOyG,eAAAA,KAAKzE,MAAM,YAAXyE,eAAe;YAC9Be,YAAYxH,QAAOyG,gBAAAA,KAAK9F,MAAM,YAAX8F,gBAAe;QAClC,GACA;YAAEgB,OAAO;gBAAE9E,MAAM;gBAAUhD,KAAKwB,OAAOxB,GAAG;gBAAEC,OAAOuB,OAAOvB,KAAK;YAAC;QAAE;IAEtE;IACA,OAAO;QAAEgF;QAAQjG,IAAI;QAAMiH;IAAc;AAC3C;AAEA,qEAAqE,GACrE,MAAM8B,iBAAiB,CAACC,UACtBA,QAAQjJ,KAAK,KAAK,gCAAgC,MAAM;AAE1D;;;;;;CAMC,GACD,OAAO,MAAMkJ,yBAA2C,OAAO7I,KAAKkH;;QAOhDlH,WAIIA;IAVtB,IAAIA,IAAImH,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAAqB;QACnD;IACF;IACA,MAAMM,QAAQX,kBAAkBU,IAAIH,IAAI;IACxC,IAAIyH,MAAMC,OAAO,EAACvH,YAAAA,IAAIH,IAAI,qBAARG,UAAU8I,OAAO,GAAG;QACpC,MAAMC,cAAc/I,KAAKkH,KAAKjH;QAC9B;IACF;IACA,MAAM4F,SAAS5E,gBAAOjB,aAAAA,IAAIH,IAAI,qBAARG,WAAU6F,MAAM,mBAAI,IAAIU,IAAI;IAClD,IAAI,CAACtG,SAAS,CAAC4F,QAAQ;QACrBqB,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAA2B;QACzD;IACF;IAEA,IAAI;QACF,MAAMyC,SAAS,MAAMrC,mBAAmBC,KAAKC;QAC7C,IAAImC,OAAOxC,EAAE,KAAK,OAAO;YACvBsH,IAAIxH,MAAM,CAAC0C,OAAO1C,MAAM,EAAE2H,IAAI,CAACjF,OAAOvC,IAAI;YAC1C;QACF;QACA,MAAM+I,UAAU,MAAMV,aAAa9F,QAAQnC,OAAOuE,gBAAgBpC,OAAO7B,KAAK,GAAGsF;QACjF,IAAI+C,QAAQhJ,EAAE,KAAK,OAAO;YACxBsH,IAAIxH,MAAM,CAACiJ,eAAeC,UAAUvB,IAAI,CAAC;gBAAE1H,OAAOiJ,QAAQjJ,KAAK;YAAC;YAChE;QACF;QACA,MAAME,OAAgC;YACpCD,IAAI;YACJiH,eAAe+B,QAAQ/B,aAAa;WAChC+B,QAAQP,WAAW,GAAG;YAAEA,aAAa;QAAK,IAAI,CAAC;QAErDnB,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAACxH;IACvB,EAAE,OAAOF,OAAO;QACdgD,QAAQhD,KAAK,CAAC,8BAA8BA;QAC5CuH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAAmC;IACnE;AACF,EAAC;AAED;;;;;CAKC,GACD,eAAeoJ,cACb/I,GAAqB,EACrBkH,GAAsB,EACtBjH,KAA2B;QAUtBD;IARL,IAAI,CAACC,SAASA,MAAMI,KAAK,KAAK,OAAO;QACnC6G,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YACnB1H,OAAO;QACT;QACA;IACF;IACA,MAAMmJ,UAAU;WACX,IAAIE,IACL,EAAChJ,YAAAA,IAAIH,IAAI,qBAARG,UAAU8I,OAAO,EAAerG,GAAG,CAAC,CAACuE,KAAO/F,OAAO+F,aAAAA,KAAM,IAAIT,IAAI,IAAI0C,MAAM,CAAC/D;KAEhF;IACD,IAAI,CAAC4D,QAAQxH,MAAM,IAAIwH,QAAQxH,MAAM,GAAGpC,oBAAoB;QAC1DgI,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YACnB1H,OAAO,CAAC,uBAAuB,EAAET,mBAAmB,iBAAiB,CAAC;QACxE;QACA;IACF;IAEA,IAAI;QACF,MAAMkD,SAAS,MAAMrC,mBAAmBC,KAAKC;QAC7C,IAAImC,OAAOxC,EAAE,KAAK,OAAO;YACvBsH,IAAIxH,MAAM,CAAC0C,OAAO1C,MAAM,EAAE2H,IAAI,CAACjF,OAAOvC,IAAI;YAC1C;QACF;QACA,MAAM8E,QAAQH,gBAAgBpC,OAAO7B,KAAK;QAC1C,MAAM0H,UAAoC,EAAE;QAC5C,KAAK,MAAMpC,UAAUiD,QAAS;YAC5Bb,QAAQD,IAAI,CAAC,MAAME,aAAa9F,QAAQnC,OAAO0E,OAAOkB;QACxD;QACA,MAAMhG,OAAiC;YAAED,IAAI;YAAMqI;QAAQ;QAC3Df,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAACxH;IACvB,EAAE,OAAOF,OAAO;QACdgD,QAAQhD,KAAK,CAAC,oCAAoCA;QAClDuH,IAAIxH,MAAM,CAAC,KAAK2H,IAAI,CAAC;YAAE1H,OAAO;QAAoC;IACpE;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-routes.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 consentGroupForHost,\n CRM_COLLECTIONS,\n type CrmTask,\n crmScopeTokens,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n crmTaskReminderPending,\n crmTaskReminderWhen,\n isOrgWideMember,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n} from '@aglyn/aglyn/server'\nimport {\n crmNextActivityLinksOf,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n notifyUsers,\n recomputeCrmNextTaskAt,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_TASK_BATCH_MAX,\n type CrmTaskCompleteOutcome,\n type CrmTaskCompleteResponse,\n type CrmTaskFields,\n type CrmTaskSaveOutcome,\n type CrmTaskSaveResponse,\n type CrmTasksCompleteResponse,\n type CrmTasksSaveResponse,\n readCrmTaskFields,\n} from '../model/task-routes'\nimport { CRM_ORG_TASK_SCOPE } from '../model/task-scope'\nimport { digestTimeZone } from '../model/crm-digest'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * The two things a task does that a browser must not do alone (AGL-2599).\n *\n * Every other task write is client-direct against the Firestore rules —\n * reopening, deleting, the list's own reads. These two are routes because\n * each has a side effect that lives outside the document:\n *\n * - SAVING a task assigned to somebody else NOTIFIES them, and a\n * notification is an Admin-SDK write into another person's inbox. Rather\n * than a client-direct write followed by a \"please notify\" call — two\n * operations that can disagree about whether the task exists — the route\n * persists the task itself and notifies from the same code path, so a\n * notification can never announce a task that failed to save. It also\n * stamps the scope, the provenance and the timestamps, which is one\n * creator computing `visibleTo` rather than two.\n *\n * - COMPLETING a task EMITS the `taskCompleted` host event, which is what a\n * workflow triggers on. A browser cannot run the workflow runner.\n *\n * Both authenticate the way the other console plugin routes do — the ID\n * token in the `Authorization` header — and then ask the same two questions\n * the rules ask of a `crmTasks` write: is this member allowed to write org\n * data (`owner`/`admin`/`editor`, and `data.manage` resolved through their\n * custom role), and can they reach THIS document's scope.\n *\n * ## The organization variant (AGL-2637)\n *\n * `{ orgId, … }` instead of a site: the org-level hub. The caller is\n * authorized by the ORG — an org-wide member holding `data.manage`, the\n * hub's own gate, never a site collaborator — and reaches every task the\n * org holds, so no scope check is made against the document. A NEW task\n * names the site it is filed from beside the org, or names none and is the\n * organization's own: `hostId: null`, the org scope token alone. Completing\n * emits on the task's OWN site, where its automations live; an\n * organization task has none and emits nothing, as an uncaptured deal's\n * stage change emits nothing.\n *\n * The org variant also takes a BATCH — `taskIds` to complete, `tasks` to\n * save — so the org hub's bulk bar makes one request per action rather\n * than one per task. Each task is answered on its own in `results`; the\n * request as a whole is refused only for what refuses every task alike.\n */\n\nexport type Refusal = {\n ok: false\n status: number\n body: { error: string; reason?: string; code?: string }\n}\n\n/** What the suite gate names when a task route refuses a plan without it. */\nconst TASKS_ACT = \"Working a record's tasks\"\n\nexport interface Writer {\n ok: true\n uid: string\n /** The caller's address, for who set off the runs a write causes (AGL-3376). */\n email: string | null\n /** The token's display name, `''` when it carries none — who an assignee is told assigned them. */\n name?: string\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n /** The member document under a site; `null` at the org level, where the reach is the org's. */\n member: Partial<AglynOrgMember> | null\n level: CrmRouteScope['level']\n}\n\nexport const refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n body: { error },\n})\n\nconst ORG_REFUSAL =\n 'Editing tasks at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.'\n\nexport interface CrmWriterOptions {\n /**\n * The act a plan without the CRM is refused for, named the way\n * `crmSuiteRefusalMessage` takes it. `null` for a route that asks the plan\n * itself, once it knows what the request writes, so the refusal can name\n * that act — a contact update names the first field it carries. Tasks\n * when unsaid.\n */\n suiteAct?: string | null\n}\n\n/**\n * Who is asking, and whether they may write this CRM at all.\n *\n * Under a site: the org resolved from the site and the rules' own role and\n * permission questions. At the organization level: the org the body names,\n * for an org-wide member with the permission. The scope question — may they\n * reach THIS task — is asked by the caller against the document, because on\n * a create the document does not exist yet and its scope is what the route\n * is about to stamp.\n */\nexport async function authorizeCrmWriter(\n req: PluginApiRequest,\n scope: CrmRouteScope,\n options: CrmWriterOptions = {},\n): Promise<Writer | Refusal> {\n const suiteAct = options.suiteAct === undefined ? TASKS_ACT : options.suiteAct\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: ORG_REFUSAL,\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n // The plan after the person (AGL-2787): see `suite-gate.ts`.\n const suite = suiteAct ? crmSuiteRefusal(caller.org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email ?? null,\n name: caller.name,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org as Record<string, unknown>,\n member: null,\n level: 'org',\n }\n }\n\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\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const resolved = await getOrgForHost(scope.hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const role = String(member?.role ?? '')\n /*\n * The rules' `canWriteOrgData()` restated: the role list is what an\n * unstamped permission map falls through to, and the permission is the\n * only half a custom role's revocation can reach. A route that checked\n * one and not the other would open a door the rules keep shut.\n */\n const writerRole = role === 'owner' || role === 'admin' || role === 'editor'\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || !writerRole || suspended) {\n return refuse(403, 'Your organization role does not allow editing the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(\n 403,\n 'Your organization role does not allow editing the CRM.',\n )\n }\n }\n const suite = suiteAct ? crmSuiteRefusal(org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n name: typeof decoded['name'] === 'string' ? decoded['name'].trim().slice(0, 120) : '',\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n level: 'site',\n }\n}\n\n/**\n * The rules' `canReadScoped()`, for a document the route has in hand. An\n * org-level writer was admitted as org-wide, and an org-wide member reads\n * every row.\n */\nexport const canReach = (writer: Writer, visibleTo: unknown): boolean =>\n writer.staff ||\n writer.level === 'org' ||\n isOrgWideMember(writer.member) ||\n memberCanSee(writer.member, visibleTo as string[] | undefined)\n\n/**\n * The records the task names carry `nextTaskAtMs` (AGL-2661), recomputed\n * after every write here. Its own catch: the task IS saved by now, and a\n * denormalized figure that could not move is logged and left for the\n * Fields section's recompute, not reported as a task that failed to save.\n */\nasync function settleNextActivity(\n orgId: string,\n links: readonly (Partial<CrmTaskFields> | Record<string, unknown> | null | undefined)[],\n): Promise<void> {\n try {\n await recomputeCrmNextTaskAt(\n firebaseAdmin.app().firestore(),\n orgId,\n links.map((link) => crmNextActivityLinksOf(link as Record<string, unknown>)),\n )\n } catch (error) {\n console.error('[crm] next activity could not be recomputed', orgId, error)\n }\n}\n\n/**\n * The console path the assignee's notification opens: the record the task\n * is for, in the hub, or the tasks list when it is for nobody in particular.\n *\n * In the `/{hostDocId}/rest` shape every host notification uses — the\n * console rewrites it to `/{orgSlug}/hosts/{subdomain}/rest` when the link\n * is followed, which is why `hostId` and `orgId` travel on the document. An\n * organization task has no site to open under, so its link is the `/org`\n * shape the console rewrites onto the organization's own hub.\n */\nfunction assignmentLink(hostId: string | null, fields: CrmTaskFields): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (fields.contactId) return `${base}/contacts/${encodeURIComponent(fields.contactId)}`\n if (fields.dealId) return `${base}/deals/${encodeURIComponent(fields.dealId)}`\n if (fields.companyId) return `${base}/companies/${encodeURIComponent(fields.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * The editable fields as a Firestore write.\n *\n * The optional ids and the assignee are ABSENT on the document when unset,\n * never `null` — the record cards query `where('contactId', '==', id)` and\n * the \"My tasks\" view `where('assigneeUid', '==', uid)`, and neither is\n * helped by a null. So a create leaves an unset optional out, and an update\n * DELETES it: an assignee cleared in the drawer has to come off the\n * document, and an update that merely omitted the key would keep them.\n * `dueAtMs` is the one field written as `null` when unset, because every\n * view orders by it and a document missing the field drops out of an\n * `orderBy` entirely.\n */\nfunction storedFields(\n fields: CrmTaskFields,\n mode: 'create' | 'update',\n): Record<string, unknown> {\n const absent = mode === 'update' ? FieldValue.delete() : undefined\n const optional = (value: string | null) =>\n value ? { present: true, value } : { present: mode === 'update', value: absent }\n const out: Record<string, unknown> = {\n title: fields.title,\n kind: fields.kind,\n priority: fields.priority,\n dueAtMs: fields.dueAtMs,\n notes: fields.notes,\n }\n for (const [key, value] of [\n ['assigneeUid', fields.assigneeUid],\n ['contactId', fields.contactId],\n ['companyId', fields.companyId],\n ['dealId', fields.dealId],\n ] as const) {\n const slot = optional(value)\n if (slot.present) out[key] = slot.value\n }\n return out\n}\n\n/**\n * The reminder a save leaves (AGL-2659), as a Firestore write.\n *\n * `crmTaskReminderAfterEdit` decides which reminder the task ends up with;\n * this turns the decision into fields. Written as `null` when unset, like\n * `dueAtMs`, so the document says the reminder was decided. A reminder that\n * MOVED — to a new time, or to none — is a reminder not yet sent, so the\n * runner's mark comes off with it; one that stayed keeps its mark, which is\n * what stops a title correction from re-sending last week's reminder.\n */\nfunction reminderFields(\n fields: CrmTaskFields,\n existing: FirebaseFirestore.DocumentSnapshot | null,\n): Record<string, unknown> {\n const previous = existing\n ? {\n dueAtMs: (existing.get('dueAtMs') as number | null | undefined) ?? null,\n remindAtMs: (existing.get('remindAtMs') as number | null | undefined) ?? null,\n }\n : null\n const remindAtMs = crmTaskReminderAfterEdit({\n dueAtMs: fields.dueAtMs,\n remindAtMs: fields.remindAtMs,\n previous,\n })\n const out: Record<string, unknown> = { remindAtMs }\n if (previous && remindAtMs !== previous.remindAtMs) {\n out['reminderSentAtMs'] = FieldValue.delete()\n }\n return out\n}\n\nconst tasksCollection = (orgId: string) =>\n firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.tasks)\n\ntype TasksCollection = ReturnType<typeof tasksCollection>\n\n/**\n * Whether a uid names a member of the org, answered once per uid across a\n * batch: a bar reassigning forty tasks to one person asks the roster once.\n */\nfunction rosterOf(orgId: string) {\n const known = new Map<string, Promise<boolean>>()\n return (uid: string): Promise<boolean> => {\n let answer = known.get(uid)\n if (!answer) {\n answer = resolveOrgMembership(uid, orgId)\n .then((found) => Boolean(found?.member))\n .catch(() => false)\n known.set(uid, answer)\n }\n return answer\n }\n}\n\n/**\n * What an assignee is told: who handed them the task, in which workspace,\n * and when it is due. It is mailed to anyone who turned that email on, and\n * reads whole without its \"Task assigned to you\" title.\n *\n * The due time is printed in the zone the reminders and the digest use, with\n * the zone named. A bare `toLocaleString()` here would print the server's\n * UTC with no zone, five hours off a Chicago reader's clock and silent\n * about it.\n */\nfunction taskAssignedBody(\n writer: Pick<Writer, 'member' | 'name' | 'email' | 'org'>,\n fields: Pick<CrmTaskFields, 'title' | 'dueAtMs'>,\n timeZone: string = digestTimeZone(),\n): string {\n const who =\n String(writer.member?.displayName ?? '').trim() ||\n String(writer.name ?? '').trim() ||\n String(writer.member?.email ?? '').trim() ||\n String(writer.email ?? '').trim() ||\n 'Someone'\n const workspace =\n String(writer.org['name'] ?? '').trim() || String(writer.org['slug'] ?? '').trim()\n const due =\n typeof fields.dueAtMs === 'number'\n ? `, due ${crmTaskReminderWhen(fields.dueAtMs, timeZone)}`\n : ''\n return `${who} assigned you a task${workspace ? ` in ${workspace}` : ''}: ${fields.title}${due}.`\n}\n\n/** Tell the assignee, when they are somebody new and somebody else. */\nasync function notifyAssignee(\n writer: Writer,\n fields: CrmTaskFields,\n previousAssignee: string | null,\n hostId: string | null,\n): Promise<boolean> {\n const assignee = fields.assigneeUid\n const notified = Boolean(\n assignee && assignee !== writer.uid && assignee !== previousAssignee,\n )\n if (!notified || !assignee) return false\n await notifyUsers([assignee], {\n type: 'content.taskAssigned',\n title: 'Task assigned to you',\n body: taskAssignedBody(writer, fields),\n link: assignmentLink(hostId, fields),\n orgId: writer.orgId,\n ...(hostId ? { hostId } : {}),\n })\n return true\n}\n\n/**\n * Rewrite one existing task's editable fields, and tell a new assignee.\n *\n * `visibleTo` is not touched: the rules let only an org-wide member move a\n * record's scope, and this route is not the place that does it. `hostId`\n * and `createdByUid` are provenance and never rewritten. The notification\n * opens under the site the caller is on, or — at the organization level —\n * under the task's own site, and under the org hub for a task with none.\n */\nasync function updateTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const existing = await tasks.doc(taskId).get()\n if (!existing.exists) return refuse(404, 'That task no longer exists.')\n if (!canReach(writer, existing.get('visibleTo'))) {\n return refuse(403, 'That task is not visible to you.')\n }\n const previousAssignee = String(existing.get('assigneeUid') ?? '') || null\n const previousLinks = crmNextActivityLinksOf(existing.data() as Record<string, unknown>)\n const stored = storedFields(fields, 'update')\n await tasks.doc(taskId).update({\n ...stored,\n ...reminderFields(fields, existing),\n // The title is what the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...existing.data(), ...stored }),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Both sides: a task moved off one deal onto another leaves neither stale.\n await settleNextActivity(writer.orgId, [previousLinks, fields])\n const linkHostId =\n scope.level === 'site'\n ? scope.hostId\n : String(existing.get('hostId') ?? '').trim() || null\n const notified = await notifyAssignee(writer, fields, previousAssignee, linkHostId)\n return { ok: true, notified }\n}\n\n/**\n * Where a NEW task is filed from: the mounted site, or at the organization\n * level the site the body names — which has to be one of THIS org's, or an\n * org-wide member of one org could stamp a task with another org's site\n * token — or no site at all, the organization's own.\n */\nasync function resolveFiledFrom(\n writer: Writer,\n scope: CrmRouteScope,\n): Promise<{ ok: true; hostId: string | null } | Refusal> {\n if (scope.level === 'site') return { ok: true, hostId: scope.hostId }\n if (!scope.hostId) return { ok: true, hostId: null }\n const owner = await getOrgForHost(scope.hostId).catch(() => null)\n if (!owner || owner.orgId !== writer.orgId) {\n return refuse(400, 'That site is not one of this organization’s.')\n }\n return { ok: true, hostId: scope.hostId }\n}\n\n/**\n * Create one task, stamped with its scope and provenance.\n *\n * The scope every CRM creator stamps for a task filed from a site: the\n * whole org when the org has chosen that, and otherwise the sites this\n * site presents as one sender with — which, undeclared, is this site\n * alone. A scoped member creating a record they could not then see is\n * refused, which is the rules' `canCreateScoped()` said before the write\n * instead of after. An organization task — no site — carries\n * `CRM_ORG_TASK_SCOPE` and `hostId: null`.\n */\nasync function createTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; taskId: string; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const filed = await resolveFiledFrom(writer, scope)\n if (filed.ok === false) return filed\n const { hostId } = filed\n const visibleTo = hostId\n ? crmScopeTokens(writer.org, consentGroupForHost(writer.org, hostId))\n : [...CRM_ORG_TASK_SCOPE]\n if (!canReach(writer, visibleTo)) {\n return refuse(403, 'Your access to this organization does not reach this site.')\n }\n const ref = tasks.doc()\n await ref.set({\n ...storedFields(fields, 'create'),\n ...reminderFields(fields, null),\n // What the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...storedFields(fields, 'create'), visibleTo }),\n status: 'open',\n completedAtMs: null,\n visibleTo,\n hostId,\n createdByUid: writer.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [fields])\n const notified = await notifyAssignee(writer, fields, null, hostId)\n return { ok: true, taskId: ref.id, notified }\n}\n\n/**\n * `POST crm/task-save` — create or update a task, and tell its assignee.\n *\n * Body: `{ hostId, taskId?, task: CrmTaskFields }` under a site;\n * `{ orgId, hostId?, taskId?, task }` at the organization level; or the\n * org-level batch `{ orgId, tasks: [{ taskId, task }] }`. Answers\n * `{ ok, taskId, notified }`, or `{ ok, results }` for the batch.\n *\n * The NOTIFICATION fires when the saved assignee is somebody other than the\n * person saving AND is not who the task was already assigned to. Assigning\n * a task to yourself is a note to self; re-saving a task's title does not\n * re-announce it to the person who already has it. The assignee must be a\n * member of the org — a uid nobody can find in the roster is refused rather\n * than written, because a notification to a stranger's inbox is the one\n * thing this route must never do.\n */\nexport const crmTaskSaveHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n if (Array.isArray(req.body?.tasks)) {\n await saveBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n const read = readCrmTaskFields(req.body?.task)\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const fields = read.fields\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n let body: CrmTaskSaveResponse\n if (taskId) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId, notified: saved.notified }\n } else {\n const saved = await createTask(writer, scope, tasks, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId: saved.taskId, notified: saved.notified }\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save failed', error)\n res.status(500).json({ error: 'The task could not be saved.' })\n }\n}\n\n/**\n * The batch: every entry read before any is written, so a body with one\n * unreadable entry is refused whole rather than half-applied; then each\n * task in order, each answered on its own.\n */\nasync function saveBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope,\n): Promise<void> {\n if (scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is saved at the organization level only.',\n })\n return\n }\n const entries = (req.body?.tasks as unknown[]).slice(0, CRM_TASK_BATCH_MAX + 1)\n if (!entries.length || entries.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Save between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n const parsed: Array<{ taskId: string; fields: CrmTaskFields }> = []\n for (const entry of entries) {\n const raw = (entry ?? {}) as Record<string, unknown>\n const taskId = String(raw['taskId'] ?? '').trim()\n const read = readCrmTaskFields(raw['task'])\n if (!taskId || read.ok === false) {\n res.status(400).json({\n error: read.ok === false ? read.error : 'Every task in a batch names its taskId.',\n })\n return\n }\n parsed.push({ taskId, fields: read.fields })\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n const results: CrmTaskSaveOutcome[] = []\n for (const { taskId, fields } of parsed) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n results.push(\n saved.ok === false\n ? { taskId, ok: false, error: saved.body.error }\n : { taskId, ok: true, notified: saved.notified },\n )\n }\n const body: CrmTasksSaveResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save batch failed', error)\n res.status(500).json({ error: 'The tasks could not be saved.' })\n }\n}\n\n/**\n * Mark one task done and fire `taskCompleted` on the site whose automations\n * hear it: under a site, the site whose console ticked the box — workflows\n * belong to a site, and the person is on that site's console. At the\n * organization level there is no such console, so the event goes to the\n * task's OWN site, and an organization task, having none, fires nothing.\n * The task's own `hostId` rides in the payload as `taskHostId` either way,\n * for a workflow that cares about the difference.\n *\n * Idempotent on purpose: two people ticking the same box, or one person\n * double-clicking, completes a task once and fires its event once. The\n * second call reads the stored `completedAtMs` back and writes nothing.\n */\nasync function completeTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n): Promise<CrmTaskCompleteOutcome> {\n const ref = tasks.doc(taskId)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { taskId, ok: false, error: 'That task no longer exists.' }\n }\n const task = snapshot.data() as Partial<CrmTask>\n if (!canReach(writer, task.visibleTo)) {\n return { taskId, ok: false, error: 'That task is not visible to you.' }\n }\n if (task.status === 'done') {\n return {\n taskId,\n ok: true,\n completedAtMs: Number(task.completedAtMs ?? 0),\n alreadyDone: true,\n }\n }\n\n const completedAtMs = Date.now()\n await ref.update({\n status: 'done',\n completedAtMs,\n completedByUid: writer.uid,\n // A reminder still owed on a task that is done is owed to nobody\n // (AGL-2659); one already sent stays, as a fact about what happened.\n ...(crmTaskReminderPending(task) ? { remindAtMs: null } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [task as Record<string, unknown>])\n\n const eventHostId =\n scope.level === 'site' ? scope.hostId : String(task.hostId ?? '').trim()\n if (eventHostId) {\n /*\n * The payload is what a workflow filter can read: every field a string,\n * number or boolean, and every optional one present as an empty string\n * rather than absent, so `contactId != \"\"` is a filter somebody can\n * write without knowing whether the key exists.\n */\n await emitHostEvent(\n eventHostId,\n 'taskCompleted',\n {\n taskId,\n title: String(task.title ?? ''),\n kind: String(task.kind ?? ''),\n priority: String(task.priority ?? ''),\n dueAtMs: typeof task.dueAtMs === 'number' ? task.dueAtMs : 0,\n completedAtMs,\n completedByUid: writer.uid,\n assigneeUid: String(task.assigneeUid ?? ''),\n createdByUid: String(task.createdByUid ?? ''),\n contactId: String(task.contactId ?? ''),\n companyId: String(task.companyId ?? ''),\n dealId: String(task.dealId ?? ''),\n taskHostId: String(task.hostId ?? ''),\n },\n { actor: { kind: 'member', uid: writer.uid, email: writer.email } },\n )\n }\n return { taskId, ok: true, completedAtMs }\n}\n\n/** A per-task outcome as the single-task route has always answered. */\nconst completeStatus = (outcome: Extract<CrmTaskCompleteOutcome, { ok: false }>) =>\n outcome.error === 'That task no longer exists.' ? 404 : 403\n\n/**\n * `POST crm/task-complete` — mark a task done and fire `taskCompleted`.\n *\n * Body: `{ hostId, taskId }` under a site, `{ orgId, taskId }` at the\n * organization level, or the org-level batch `{ orgId, taskIds }`. Answers\n * `{ ok, completedAtMs, alreadyDone? }`, or `{ ok, results }` for the batch.\n */\nexport const crmTaskCompleteHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (Array.isArray(req.body?.taskIds)) {\n await completeBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n if (!scope || !taskId) {\n res.status(400).json({ error: 'Missing hostId or taskId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const outcome = await completeTask(writer, scope, tasksCollection(writer.orgId), taskId)\n if (outcome.ok === false) {\n res.status(completeStatus(outcome)).json({ error: outcome.error })\n return\n }\n const body: CrmTaskCompleteResponse = {\n ok: true,\n completedAtMs: outcome.completedAtMs,\n ...(outcome.alreadyDone ? { alreadyDone: true } : {}),\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete failed', error)\n res.status(500).json({ error: 'The task could not be completed.' })\n }\n}\n\n/**\n * The batch: the ids deduplicated and capped, then each task in order —\n * in order rather than at once, because each completion may run a site's\n * workflows, and two hundred runners started together is the burst the\n * per-task client call was written to avoid.\n */\nasync function completeBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope | null,\n): Promise<void> {\n if (!scope || scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is completed at the organization level only.',\n })\n return\n }\n const taskIds = [\n ...new Set(\n (req.body?.taskIds as unknown[]).map((id) => String(id ?? '').trim()).filter(Boolean),\n ),\n ]\n if (!taskIds.length || taskIds.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Complete between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const results: CrmTaskCompleteOutcome[] = []\n for (const taskId of taskIds) {\n results.push(await completeTask(writer, scope, tasks, taskId))\n }\n const body: CrmTasksCompleteResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete batch failed', error)\n res.status(500).json({ error: 'The tasks could not be completed.' })\n }\n}\n"],"names":["consentGroupForHost","CRM_COLLECTIONS","crmScopeTokens","crmTaskListFields","crmTaskReminderAfterEdit","crmTaskReminderPending","crmTaskReminderWhen","isOrgWideMember","memberCanSee","crmNextActivityLinksOf","firebaseAdmin","getOrgForHost","memberHasOrgPermission","notifyUsers","recomputeCrmNextTaskAt","resolveOrgMembership","emitHostEvent","FieldValue","CRM_TASK_BATCH_MAX","readCrmTaskFields","CRM_ORG_TASK_SCOPE","digestTimeZone","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","TASKS_ACT","refuse","status","error","ok","body","ORG_REFUSAL","authorizeCrmWriter","req","scope","options","suiteAct","undefined","level","caller","orgId","needs","refusal","suite","org","uid","email","name","staff","member","authorization","String","headers","idToken","startsWith","slice","length","decoded","app","auth","verifyIdToken","resolved","hostId","catch","membership","role","writerRole","suspended","orgSuspended","trim","canReach","writer","visibleTo","settleNextActivity","links","firestore","map","link","console","assignmentLink","fields","base","contactId","encodeURIComponent","dealId","companyId","storedFields","mode","absent","delete","optional","value","present","out","title","kind","priority","dueAtMs","notes","key","assigneeUid","slot","reminderFields","existing","previous","get","remindAtMs","tasksCollection","collection","doc","tasks","rosterOf","known","Map","answer","then","found","Boolean","set","taskAssignedBody","timeZone","who","displayName","workspace","due","notifyAssignee","previousAssignee","assignee","notified","type","updateTask","taskId","onRoster","exists","previousLinks","data","stored","update","updatedAt","serverTimestamp","linkHostId","resolveFiledFrom","owner","createTask","filed","ref","completedAtMs","createdByUid","createdAt","id","crmTaskSaveHandler","res","method","setHeader","json","Array","isArray","saveBatch","read","task","saved","entries","parsed","entry","raw","push","results","completeTask","snapshot","Number","alreadyDone","Date","now","completedByUid","eventHostId","taskHostId","actor","completeStatus","outcome","crmTaskCompleteHandler","taskIds","completeBatch","Set","filter"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,mBAAmB,EACnBC,eAAe,EAEfC,cAAc,EACdC,iBAAiB,EACjBC,wBAAwB,EACxBC,sBAAsB,EACtBC,mBAAmB,EACnBC,eAAe,EACfC,YAAY,QAIP,sBAAqB;AAC5B,SACEC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,sBAAsB,EACtBC,WAAW,EACXC,sBAAsB,EACtBC,oBAAoB,QACf,2BAA0B;AACjC,SAASC,aAAa,QAAQ,wBAAuB;AACrD,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,kBAAkB,EAQlBC,iBAAiB,QACZ,0BAAsB;AAC7B,SAASC,kBAAkB,QAAQ,yBAAqB;AACxD,SAASC,cAAc,QAAQ,yBAAqB;AACpD,SACEC,kBAAkB,EAElBC,iBAAiB,QACZ,kBAAc;AACrB,SAASC,eAAe,QAAQ,kBAAc;AAmD9C,2EAA2E,GAC3E,MAAMC,YAAY;AAiBlB,OAAO,MAAMC,SAAS,CAACC,QAAgBC,QAA4B,CAAA;QACjEC,IAAI;QACJF;QACAG,MAAM;YAAEF;QAAM;IAChB,CAAA,EAAE;AAEF,MAAMG,cACJ,wEACA;AAaF;;;;;;;;;CASC,GACD,OAAO,eAAeC,mBACpBC,GAAqB,EACrBC,KAAoB,EACpBC,UAA4B,CAAC,CAAC;QAyBDF;IAvB7B,MAAMG,WAAWD,QAAQC,QAAQ,KAAKC,YAAYZ,YAAYU,QAAQC,QAAQ;IAC9E,IAAIF,MAAMI,KAAK,KAAK,OAAO;YAYhBC;QAXT,MAAMA,SAAS,MAAMjB,mBAAmBW,KAAKC,MAAMM,KAAK,EAAE;YACxDC,OAAO;YACPC,SAASX;QACX;QACA,IAAIQ,OAAOV,EAAE,KAAK,OAAO,OAAOH,OAAOa,OAAOZ,MAAM,EAAEY,OAAOX,KAAK;QAClE,6DAA6D;QAC7D,MAAMe,QAAQP,WAAWZ,gBAAgBe,OAAOK,GAAG,EAAER,YAAY;QACjE,IAAIO,OAAO,OAAO;YAAEd,IAAI;YAAOF,QAAQgB,MAAMhB,MAAM;YAAEG,MAAMa,MAAMb,IAAI;QAAC;QACtE,OAAO;YACLD,IAAI;YACJgB,KAAKN,OAAOM,GAAG;YACfC,KAAK,GAAEP,gBAAAA,OAAOO,KAAK,YAAZP,gBAAgB;YACvBQ,MAAMR,OAAOQ,IAAI;YACjBC,OAAOT,OAAOS,KAAK;YACnBR,OAAOD,OAAOC,KAAK;YACnBI,KAAKL,OAAOK,GAAG;YACfK,QAAQ;YACRX,OAAO;QACT;IACF;IAEA,MAAMY,gBAAgBC,QAAOlB,6BAAAA,IAAImB,OAAO,CAACF,aAAa,YAAzBjB,6BAA6B;IAC1D,MAAMoB,UAAUH,cAAcI,UAAU,CAAC,aACrCJ,cAAcK,KAAK,CAAC,UAAUC,MAAM,IACpCnB;IACJ,IAAI,CAACgB,SAAS,OAAO3B,OAAO,KAAK;IAEjC,MAAM+B,UAAU,MAAM/C,cAAcgD,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACP;IAC/D,MAAML,QAAQS,OAAO,CAAC,QAAQ,KAAK;IACnC,MAAMI,WAAW,MAAMlD,cAAcuB,MAAM4B,MAAM,EAAEC,KAAK,CAAC,IAAM;IAC/D,IAAI,CAACF,UAAU;QACb,OAAOnC,OAAO,KAAK;IACrB;IACA,MAAM,EAAEc,KAAK,EAAEI,GAAG,EAAE,GAAGiB;IACvB,MAAMG,aAAa,MAAMjD,qBAAqB0C,QAAQZ,GAAG,EAAEL,OAAOuB,KAAK,CACrE,IAAM;IAER,MAAMd,iBAAUe,8BAAAA,WAAYf,MAAM,mBAAI;IACtC,IAAI,CAACD,OAAO;;QACV,MAAMiB,OAAOd,gBAAOF,0BAAAA,OAAQgB,IAAI,oBAAI;QACpC;;;;;KAKC,GACD,MAAMC,aAAaD,SAAS,WAAWA,SAAS,WAAWA,SAAS;QACpE,MAAME,YACJ,CAAClB,0BAAD,AAACA,OAA8CmB,YAAY,MAAK;QAClE,IAAI,CAACnB,UAAU,CAACiB,cAAcC,WAAW;YACvC,OAAOzC,OAAO,KAAK;QACrB;QACA,IAAI,CAAE,MAAMd,uBAAuB4B,OAAOS,QAAQ,gBAAiB;YACjE,OAAOvB,OACL,KACA;QAEJ;IACF;IACA,MAAMiB,QAAQP,WAAWZ,gBAAgBoB,KAAKR,YAAY;IAC1D,IAAIO,OAAO,OAAO;QAAEd,IAAI;QAAOF,QAAQgB,MAAMhB,MAAM;QAAEG,MAAMa,MAAMb,IAAI;IAAC;IACtE,OAAO;QACLD,IAAI;QACJgB,KAAKY,QAAQZ,GAAG;QAChBC,OAAO,OAAOW,QAAQX,KAAK,KAAK,WAAWW,QAAQX,KAAK,GAAG;QAC3DC,MAAM,OAAOU,OAAO,CAAC,OAAO,KAAK,WAAWA,OAAO,CAAC,OAAO,CAACY,IAAI,GAAGd,KAAK,CAAC,GAAG,OAAO;QACnFP;QACAR;QACAI,GAAG,EAAGA,cAAAA,MAAO,CAAC;QACdK;QACAX,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMgC,WAAW,CAACC,QAAgBC,YACvCD,OAAOvB,KAAK,IACZuB,OAAOjC,KAAK,KAAK,SACjB/B,gBAAgBgE,OAAOtB,MAAM,KAC7BzC,aAAa+D,OAAOtB,MAAM,EAAEuB,WAAkC;AAEhE;;;;;CAKC,GACD,eAAeC,mBACbjC,KAAa,EACbkC,KAAuF;IAEvF,IAAI;QACF,MAAM5D,uBACJJ,cAAcgD,GAAG,GAAGiB,SAAS,IAC7BnC,OACAkC,MAAME,GAAG,CAAC,CAACC,OAASpE,uBAAuBoE;IAE/C,EAAE,OAAOjD,OAAO;QACdkD,QAAQlD,KAAK,CAAC,+CAA+CY,OAAOZ;IACtE;AACF;AAEA;;;;;;;;;CASC,GACD,SAASmD,eAAejB,MAAqB,EAAEkB,MAAqB;IAClE,MAAMC,OAAOnB,SAAS,CAAC,CAAC,EAAEA,OAAO,IAAI,CAAC,GAAG;IACzC,IAAIkB,OAAOE,SAAS,EAAE,OAAO,GAAGD,KAAK,UAAU,EAAEE,mBAAmBH,OAAOE,SAAS,GAAG;IACvF,IAAIF,OAAOI,MAAM,EAAE,OAAO,GAAGH,KAAK,OAAO,EAAEE,mBAAmBH,OAAOI,MAAM,GAAG;IAC9E,IAAIJ,OAAOK,SAAS,EAAE,OAAO,GAAGJ,KAAK,WAAW,EAAEE,mBAAmBH,OAAOK,SAAS,GAAG;IACxF,OAAO,GAAGJ,KAAK,MAAM,CAAC;AACxB;AAEA;;;;;;;;;;;;CAYC,GACD,SAASK,aACPN,MAAqB,EACrBO,IAAyB;IAEzB,MAAMC,SAASD,SAAS,WAAWtE,WAAWwE,MAAM,KAAKpD;IACzD,MAAMqD,WAAW,CAACC,QAChBA,QAAQ;YAAEC,SAAS;YAAMD;QAAM,IAAI;YAAEC,SAASL,SAAS;YAAUI,OAAOH;QAAO;IACjF,MAAMK,MAA+B;QACnCC,OAAOd,OAAOc,KAAK;QACnBC,MAAMf,OAAOe,IAAI;QACjBC,UAAUhB,OAAOgB,QAAQ;QACzBC,SAASjB,OAAOiB,OAAO;QACvBC,OAAOlB,OAAOkB,KAAK;IACrB;IACA,KAAK,MAAM,CAACC,KAAKR,MAAM,IAAI;QACzB;YAAC;YAAeX,OAAOoB,WAAW;SAAC;QACnC;YAAC;YAAapB,OAAOE,SAAS;SAAC;QAC/B;YAAC;YAAaF,OAAOK,SAAS;SAAC;QAC/B;YAAC;YAAUL,OAAOI,MAAM;SAAC;KAC1B,CAAW;QACV,MAAMiB,OAAOX,SAASC;QACtB,IAAIU,KAAKT,OAAO,EAAEC,GAAG,CAACM,IAAI,GAAGE,KAAKV,KAAK;IACzC;IACA,OAAOE;AACT;AAEA;;;;;;;;;CASC,GACD,SAASS,eACPtB,MAAqB,EACrBuB,QAAmD;QAInCA,eACGA;IAHnB,MAAMC,WAAWD,WACb;QACEN,OAAO,GAAGM,gBAAAA,SAASE,GAAG,CAAC,sBAAbF,gBAAyD;QACnEG,UAAU,GAAGH,iBAAAA,SAASE,GAAG,CAAC,yBAAbF,iBAA4D;IAC3E,IACA;IACJ,MAAMG,aAAatG,yBAAyB;QAC1C6F,SAASjB,OAAOiB,OAAO;QACvBS,YAAY1B,OAAO0B,UAAU;QAC7BF;IACF;IACA,MAAMX,MAA+B;QAAEa;IAAW;IAClD,IAAIF,YAAYE,eAAeF,SAASE,UAAU,EAAE;QAClDb,GAAG,CAAC,mBAAmB,GAAG5E,WAAWwE,MAAM;IAC7C;IACA,OAAOI;AACT;AAEA,MAAMc,kBAAkB,CAACnE,QACvB9B,cACGgD,GAAG,GACHiB,SAAS,GACTiC,UAAU,CAAC,QACXC,GAAG,CAACrE,OACJoE,UAAU,CAAC3G,gBAAgB6G,KAAK;AAIrC;;;CAGC,GACD,SAASC,SAASvE,KAAa;IAC7B,MAAMwE,QAAQ,IAAIC;IAClB,OAAO,CAACpE;QACN,IAAIqE,SAASF,MAAMP,GAAG,CAAC5D;QACvB,IAAI,CAACqE,QAAQ;YACXA,SAASnG,qBAAqB8B,KAAKL,OAChC2E,IAAI,CAAC,CAACC,QAAUC,QAAQD,yBAAAA,MAAOnE,MAAM,GACrCc,KAAK,CAAC,IAAM;YACfiD,MAAMM,GAAG,CAACzE,KAAKqE;QACjB;QACA,OAAOA;IACT;AACF;AAEA;;;;;;;;;CASC,GACD,SAASK,iBACPhD,MAAyD,EACzDS,MAAgD,EAChDwC,WAAmBnG,gBAAgB;cAI1BkD,qBAEAA,eAGAA,kBAA2CA;QAN3CA,gBAEAA;IAHT,MAAMkD,MACJtE,gBAAOoB,iBAAAA,OAAOtB,MAAM,qBAAbsB,eAAemD,WAAW,mBAAI,IAAIrD,IAAI,MAC7ClB,QAAOoB,eAAAA,OAAOxB,IAAI,YAAXwB,eAAe,IAAIF,IAAI,MAC9BlB,iBAAOoB,kBAAAA,OAAOtB,MAAM,qBAAbsB,gBAAezB,KAAK,oBAAI,IAAIuB,IAAI,MACvClB,QAAOoB,gBAAAA,OAAOzB,KAAK,YAAZyB,gBAAgB,IAAIF,IAAI,MAC/B;IACF,MAAMsD,YACJxE,QAAOoB,mBAAAA,OAAO3B,GAAG,CAAC,OAAO,YAAlB2B,mBAAsB,IAAIF,IAAI,MAAMlB,QAAOoB,mBAAAA,OAAO3B,GAAG,CAAC,OAAO,YAAlB2B,mBAAsB,IAAIF,IAAI;IAClF,MAAMuD,MACJ,OAAO5C,OAAOiB,OAAO,KAAK,WACtB,CAAC,MAAM,EAAE3F,oBAAoB0E,OAAOiB,OAAO,EAAEuB,WAAW,GACxD;IACN,OAAO,GAAGC,IAAI,oBAAoB,EAAEE,YAAY,CAAC,IAAI,EAAEA,WAAW,GAAG,GAAG,EAAE,EAAE3C,OAAOc,KAAK,GAAG8B,IAAI,CAAC,CAAC;AACnG;AAEA,qEAAqE,GACrE,eAAeC,eACbtD,MAAc,EACdS,MAAqB,EACrB8C,gBAA+B,EAC/BhE,MAAqB;IAErB,MAAMiE,WAAW/C,OAAOoB,WAAW;IACnC,MAAM4B,WAAWX,QACfU,YAAYA,aAAaxD,OAAO1B,GAAG,IAAIkF,aAAaD;IAEtD,IAAI,CAACE,YAAY,CAACD,UAAU,OAAO;IACnC,MAAMlH,YAAY;QAACkH;KAAS,EAAE;QAC5BE,MAAM;QACNnC,OAAO;QACPhE,MAAMyF,iBAAiBhD,QAAQS;QAC/BH,MAAME,eAAejB,QAAQkB;QAC7BxC,OAAO+B,OAAO/B,KAAK;OACfsB,SAAS;QAAEA;IAAO,IAAI,CAAC;IAE7B,OAAO;AACT;AAEA;;;;;;;;CAQC,GACD,eAAeoE,WACb3D,MAAc,EACdrC,KAAoB,EACpB4E,KAAsB,EACtBqB,MAAc,EACdnD,MAAqB,EACrBoD,QAA2C;QAUX7B,eAenBA;IAvBb,IAAIvB,OAAOoB,WAAW,IAAI,CAAE,MAAMgC,SAASpD,OAAOoB,WAAW,GAAI;QAC/D,OAAO1E,OAAO,KAAK;IACrB;IACA,MAAM6E,WAAW,MAAMO,MAAMD,GAAG,CAACsB,QAAQ1B,GAAG;IAC5C,IAAI,CAACF,SAAS8B,MAAM,EAAE,OAAO3G,OAAO,KAAK;IACzC,IAAI,CAAC4C,SAASC,QAAQgC,SAASE,GAAG,CAAC,eAAe;QAChD,OAAO/E,OAAO,KAAK;IACrB;IACA,MAAMoG,mBAAmB3E,QAAOoD,gBAAAA,SAASE,GAAG,CAAC,0BAAbF,gBAA+B,OAAO;IACtE,MAAM+B,gBAAgB7H,uBAAuB8F,SAASgC,IAAI;IAC1D,MAAMC,SAASlD,aAAaN,QAAQ;IACpC,MAAM8B,MAAMD,GAAG,CAACsB,QAAQM,MAAM,CAAC,aAC1BD,QACAlC,eAAetB,QAAQuB,WAEvBpG,kBAAkB,aAAKoG,SAASgC,IAAI,IAAOC;QAC9CE,WAAWzH,WAAW0H,eAAe;;IAEvC,2EAA2E;IAC3E,MAAMlE,mBAAmBF,OAAO/B,KAAK,EAAE;QAAC8F;QAAetD;KAAO;IAC9D,MAAM4D,aACJ1G,MAAMI,KAAK,KAAK,SACZJ,MAAM4B,MAAM,GACZX,QAAOoD,iBAAAA,SAASE,GAAG,CAAC,qBAAbF,iBAA0B,IAAIlC,IAAI,MAAM;IACrD,MAAM2D,WAAW,MAAMH,eAAetD,QAAQS,QAAQ8C,kBAAkBc;IACxE,OAAO;QAAE/G,IAAI;QAAMmG;IAAS;AAC9B;AAEA;;;;;CAKC,GACD,eAAea,iBACbtE,MAAc,EACdrC,KAAoB;IAEpB,IAAIA,MAAMI,KAAK,KAAK,QAAQ,OAAO;QAAET,IAAI;QAAMiC,QAAQ5B,MAAM4B,MAAM;IAAC;IACpE,IAAI,CAAC5B,MAAM4B,MAAM,EAAE,OAAO;QAAEjC,IAAI;QAAMiC,QAAQ;IAAK;IACnD,MAAMgF,QAAQ,MAAMnI,cAAcuB,MAAM4B,MAAM,EAAEC,KAAK,CAAC,IAAM;IAC5D,IAAI,CAAC+E,SAASA,MAAMtG,KAAK,KAAK+B,OAAO/B,KAAK,EAAE;QAC1C,OAAOd,OAAO,KAAK;IACrB;IACA,OAAO;QAAEG,IAAI;QAAMiC,QAAQ5B,MAAM4B,MAAM;IAAC;AAC1C;AAEA;;;;;;;;;;CAUC,GACD,eAAeiF,WACbxE,MAAc,EACdrC,KAAoB,EACpB4E,KAAsB,EACtB9B,MAAqB,EACrBoD,QAA2C;IAE3C,IAAIpD,OAAOoB,WAAW,IAAI,CAAE,MAAMgC,SAASpD,OAAOoB,WAAW,GAAI;QAC/D,OAAO1E,OAAO,KAAK;IACrB;IACA,MAAMsH,QAAQ,MAAMH,iBAAiBtE,QAAQrC;IAC7C,IAAI8G,MAAMnH,EAAE,KAAK,OAAO,OAAOmH;IAC/B,MAAM,EAAElF,MAAM,EAAE,GAAGkF;IACnB,MAAMxE,YAAYV,SACd5D,eAAeqE,OAAO3B,GAAG,EAAE5C,oBAAoBuE,OAAO3B,GAAG,EAAEkB,WAC3D;WAAI1C;KAAmB;IAC3B,IAAI,CAACkD,SAASC,QAAQC,YAAY;QAChC,OAAO9C,OAAO,KAAK;IACrB;IACA,MAAMuH,MAAMnC,MAAMD,GAAG;IACrB,MAAMoC,IAAI3B,GAAG,CAAC,aACThC,aAAaN,QAAQ,WACrBsB,eAAetB,QAAQ,OAEvB7E,kBAAkB,aAAKmF,aAAaN,QAAQ;QAAWR;;QAC1D7C,QAAQ;QACRuH,eAAe;QACf1E;QACAV;QACAqF,cAAc5E,OAAO1B,GAAG;QACxBuG,WAAWnI,WAAW0H,eAAe;QACrCD,WAAWzH,WAAW0H,eAAe;;IAEvC,MAAMlE,mBAAmBF,OAAO/B,KAAK,EAAE;QAACwC;KAAO;IAC/C,MAAMgD,WAAW,MAAMH,eAAetD,QAAQS,QAAQ,MAAMlB;IAC5D,OAAO;QAAEjC,IAAI;QAAMsG,QAAQc,IAAII,EAAE;QAAErB;IAAS;AAC9C;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMsB,qBAAuC,OAAOrH,KAAKsH;;QAW5CtH,WAIIA,YACSA;IAf/B,IAAIA,IAAIuH,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAAqB;QACnD;IACF;IACA,MAAMM,QAAQX,kBAAkBU,IAAIH,IAAI;IACxC,IAAI,CAACI,OAAO;QACVqH,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAAiB;QAC/C;IACF;IACA,IAAI+H,MAAMC,OAAO,EAAC3H,YAAAA,IAAIH,IAAI,qBAARG,UAAU6E,KAAK,GAAG;QAClC,MAAM+C,UAAU5H,KAAKsH,KAAKrH;QAC1B;IACF;IACA,MAAMiG,SAAShF,gBAAOlB,aAAAA,IAAIH,IAAI,qBAARG,WAAUkG,MAAM,mBAAI,IAAI9D,IAAI;IAClD,MAAMyF,OAAO3I,mBAAkBc,aAAAA,IAAIH,IAAI,qBAARG,WAAU8H,IAAI;IAC7C,IAAID,KAAKjI,EAAE,KAAK,OAAO;QACrB0H,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAOkI,KAAKlI,KAAK;QAAC;QACzC;IACF;IACA,MAAMoD,SAAS8E,KAAK9E,MAAM;IAE1B,IAAI;QACF,MAAMT,SAAS,MAAMvC,mBAAmBC,KAAKC;QAC7C,IAAIqC,OAAO1C,EAAE,KAAK,OAAO;YACvB0H,IAAI5H,MAAM,CAAC4C,OAAO5C,MAAM,EAAE+H,IAAI,CAACnF,OAAOzC,IAAI;YAC1C;QACF;QACA,MAAMgF,QAAQH,gBAAgBpC,OAAO/B,KAAK;QAC1C,MAAM4F,WAAWrB,SAASxC,OAAO/B,KAAK;QACtC,IAAIV;QACJ,IAAIqG,QAAQ;YACV,MAAM6B,QAAQ,MAAM9B,WAAW3D,QAAQrC,OAAO4E,OAAOqB,QAAQnD,QAAQoD;YACrE,IAAI4B,MAAMnI,EAAE,KAAK,OAAO;gBACtB0H,IAAI5H,MAAM,CAACqI,MAAMrI,MAAM,EAAE+H,IAAI,CAACM,MAAMlI,IAAI;gBACxC;YACF;YACAA,OAAO;gBAAED,IAAI;gBAAMsG;gBAAQH,UAAUgC,MAAMhC,QAAQ;YAAC;QACtD,OAAO;YACL,MAAMgC,QAAQ,MAAMjB,WAAWxE,QAAQrC,OAAO4E,OAAO9B,QAAQoD;YAC7D,IAAI4B,MAAMnI,EAAE,KAAK,OAAO;gBACtB0H,IAAI5H,MAAM,CAACqI,MAAMrI,MAAM,EAAE+H,IAAI,CAACM,MAAMlI,IAAI;gBACxC;YACF;YACAA,OAAO;gBAAED,IAAI;gBAAMsG,QAAQ6B,MAAM7B,MAAM;gBAAEH,UAAUgC,MAAMhC,QAAQ;YAAC;QACpE;QACAuB,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC5H;IACvB,EAAE,OAAOF,OAAO;QACdkD,QAAQlD,KAAK,CAAC,0BAA0BA;QACxC2H,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAA+B;IAC/D;AACF,EAAC;AAED;;;;CAIC,GACD,eAAeiI,UACb5H,GAAqB,EACrBsH,GAAsB,EACtBrH,KAAoB;QAQHD;IANjB,IAAIC,MAAMI,KAAK,KAAK,OAAO;QACzBiH,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YACnB9H,OAAO;QACT;QACA;IACF;IACA,MAAMqI,UAAU,EAAChI,YAAAA,IAAIH,IAAI,qBAARG,UAAU6E,KAAK,EAAevD,KAAK,CAAC,GAAGrC,qBAAqB;IAC7E,IAAI,CAAC+I,QAAQzG,MAAM,IAAIyG,QAAQzG,MAAM,GAAGtC,oBAAoB;QAC1DqI,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YACnB9H,OAAO,CAAC,mBAAmB,EAAEV,mBAAmB,iBAAiB,CAAC;QACpE;QACA;IACF;IACA,MAAMgJ,SAA2D,EAAE;IACnE,KAAK,MAAMC,SAASF,QAAS;YAELG;QADtB,MAAMA,MAAOD,gBAAAA,QAAS,CAAC;QACvB,MAAMhC,SAAShF,QAAOiH,cAAAA,GAAG,CAAC,SAAS,YAAbA,cAAiB,IAAI/F,IAAI;QAC/C,MAAMyF,OAAO3I,kBAAkBiJ,GAAG,CAAC,OAAO;QAC1C,IAAI,CAACjC,UAAU2B,KAAKjI,EAAE,KAAK,OAAO;YAChC0H,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;gBACnB9H,OAAOkI,KAAKjI,EAAE,KAAK,QAAQiI,KAAKlI,KAAK,GAAG;YAC1C;YACA;QACF;QACAsI,OAAOG,IAAI,CAAC;YAAElC;YAAQnD,QAAQ8E,KAAK9E,MAAM;QAAC;IAC5C;IAEA,IAAI;QACF,MAAMT,SAAS,MAAMvC,mBAAmBC,KAAKC;QAC7C,IAAIqC,OAAO1C,EAAE,KAAK,OAAO;YACvB0H,IAAI5H,MAAM,CAAC4C,OAAO5C,MAAM,EAAE+H,IAAI,CAACnF,OAAOzC,IAAI;YAC1C;QACF;QACA,MAAMgF,QAAQH,gBAAgBpC,OAAO/B,KAAK;QAC1C,MAAM4F,WAAWrB,SAASxC,OAAO/B,KAAK;QACtC,MAAM8H,UAAgC,EAAE;QACxC,KAAK,MAAM,EAAEnC,MAAM,EAAEnD,MAAM,EAAE,IAAIkF,OAAQ;YACvC,MAAMF,QAAQ,MAAM9B,WAAW3D,QAAQrC,OAAO4E,OAAOqB,QAAQnD,QAAQoD;YACrEkC,QAAQD,IAAI,CACVL,MAAMnI,EAAE,KAAK,QACT;gBAAEsG;gBAAQtG,IAAI;gBAAOD,OAAOoI,MAAMlI,IAAI,CAACF,KAAK;YAAC,IAC7C;gBAAEuG;gBAAQtG,IAAI;gBAAMmG,UAAUgC,MAAMhC,QAAQ;YAAC;QAErD;QACA,MAAMlG,OAA6B;YAAED,IAAI;YAAMyI;QAAQ;QACvDf,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC5H;IACvB,EAAE,OAAOF,OAAO;QACdkD,QAAQlD,KAAK,CAAC,gCAAgCA;QAC9C2H,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAAgC;IAChE;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,eAAe2I,aACbhG,MAAc,EACdrC,KAAoB,EACpB4E,KAAsB,EACtBqB,MAAc;QAiCmC4B;IA/BjD,MAAMd,MAAMnC,MAAMD,GAAG,CAACsB;IACtB,MAAMqC,WAAW,MAAMvB,IAAIxC,GAAG;IAC9B,IAAI,CAAC+D,SAASnC,MAAM,EAAE;QACpB,OAAO;YAAEF;YAAQtG,IAAI;YAAOD,OAAO;QAA8B;IACnE;IACA,MAAMmI,OAAOS,SAASjC,IAAI;IAC1B,IAAI,CAACjE,SAASC,QAAQwF,KAAKvF,SAAS,GAAG;QACrC,OAAO;YAAE2D;YAAQtG,IAAI;YAAOD,OAAO;QAAmC;IACxE;IACA,IAAImI,KAAKpI,MAAM,KAAK,QAAQ;YAIFoI;QAHxB,OAAO;YACL5B;YACAtG,IAAI;YACJqH,eAAeuB,QAAOV,sBAAAA,KAAKb,aAAa,YAAlBa,sBAAsB;YAC5CW,aAAa;QACf;IACF;IAEA,MAAMxB,gBAAgByB,KAAKC,GAAG;IAC9B,MAAM3B,IAAIR,MAAM,CAAC;QACf9G,QAAQ;QACRuH;QACA2B,gBAAgBtG,OAAO1B,GAAG;OAGtBxC,uBAAuB0J,QAAQ;QAAErD,YAAY;IAAK,IAAI,CAAC;QAC3DgC,WAAWzH,WAAW0H,eAAe;;IAEvC,MAAMlE,mBAAmBF,OAAO/B,KAAK,EAAE;QAACuH;KAAgC;IAExE,MAAMe,cACJ5I,MAAMI,KAAK,KAAK,SAASJ,MAAM4B,MAAM,GAAGX,QAAO4G,eAAAA,KAAKjG,MAAM,YAAXiG,eAAe,IAAI1F,IAAI;IACxE,IAAIyG,aAAa;YAYCf,aACDA,YACIA,gBAIGA,mBACCA,oBACHA,iBACAA,iBACHA,cACIA;QAtBrB;;;;;KAKC,GACD,MAAM/I,cACJ8J,aACA,iBACA;YACA3C;YACArC,OAAO3C,QAAO4G,cAAAA,KAAKjE,KAAK,YAAViE,cAAc;YAC5BhE,MAAM5C,QAAO4G,aAAAA,KAAKhE,IAAI,YAATgE,aAAa;YAC1B/D,UAAU7C,QAAO4G,iBAAAA,KAAK/D,QAAQ,YAAb+D,iBAAiB;YAClC9D,SAAS,OAAO8D,KAAK9D,OAAO,KAAK,WAAW8D,KAAK9D,OAAO,GAAG;YAC3DiD;YACA2B,gBAAgBtG,OAAO1B,GAAG;YAC1BuD,aAAajD,QAAO4G,oBAAAA,KAAK3D,WAAW,YAAhB2D,oBAAoB;YACxCZ,cAAchG,QAAO4G,qBAAAA,KAAKZ,YAAY,YAAjBY,qBAAqB;YAC1C7E,WAAW/B,QAAO4G,kBAAAA,KAAK7E,SAAS,YAAd6E,kBAAkB;YACpC1E,WAAWlC,QAAO4G,kBAAAA,KAAK1E,SAAS,YAAd0E,kBAAkB;YACpC3E,QAAQjC,QAAO4G,eAAAA,KAAK3E,MAAM,YAAX2E,eAAe;YAC9BgB,YAAY5H,QAAO4G,gBAAAA,KAAKjG,MAAM,YAAXiG,gBAAe;QAClC,GACA;YAAEiB,OAAO;gBAAEjF,MAAM;gBAAUlD,KAAK0B,OAAO1B,GAAG;gBAAEC,OAAOyB,OAAOzB,KAAK;YAAC;QAAE;IAEtE;IACA,OAAO;QAAEqF;QAAQtG,IAAI;QAAMqH;IAAc;AAC3C;AAEA,qEAAqE,GACrE,MAAM+B,iBAAiB,CAACC,UACtBA,QAAQtJ,KAAK,KAAK,gCAAgC,MAAM;AAE1D;;;;;;CAMC,GACD,OAAO,MAAMuJ,yBAA2C,OAAOlJ,KAAKsH;;QAOhDtH,WAIIA;IAVtB,IAAIA,IAAIuH,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAAqB;QACnD;IACF;IACA,MAAMM,QAAQX,kBAAkBU,IAAIH,IAAI;IACxC,IAAI6H,MAAMC,OAAO,EAAC3H,YAAAA,IAAIH,IAAI,qBAARG,UAAUmJ,OAAO,GAAG;QACpC,MAAMC,cAAcpJ,KAAKsH,KAAKrH;QAC9B;IACF;IACA,MAAMiG,SAAShF,gBAAOlB,aAAAA,IAAIH,IAAI,qBAARG,WAAUkG,MAAM,mBAAI,IAAI9D,IAAI;IAClD,IAAI,CAACnC,SAAS,CAACiG,QAAQ;QACrBoB,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAA2B;QACzD;IACF;IAEA,IAAI;QACF,MAAM2C,SAAS,MAAMvC,mBAAmBC,KAAKC;QAC7C,IAAIqC,OAAO1C,EAAE,KAAK,OAAO;YACvB0H,IAAI5H,MAAM,CAAC4C,OAAO5C,MAAM,EAAE+H,IAAI,CAACnF,OAAOzC,IAAI;YAC1C;QACF;QACA,MAAMoJ,UAAU,MAAMX,aAAahG,QAAQrC,OAAOyE,gBAAgBpC,OAAO/B,KAAK,GAAG2F;QACjF,IAAI+C,QAAQrJ,EAAE,KAAK,OAAO;YACxB0H,IAAI5H,MAAM,CAACsJ,eAAeC,UAAUxB,IAAI,CAAC;gBAAE9H,OAAOsJ,QAAQtJ,KAAK;YAAC;YAChE;QACF;QACA,MAAME,OAAgC;YACpCD,IAAI;YACJqH,eAAegC,QAAQhC,aAAa;WAChCgC,QAAQR,WAAW,GAAG;YAAEA,aAAa;QAAK,IAAI,CAAC;QAErDnB,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC5H;IACvB,EAAE,OAAOF,OAAO;QACdkD,QAAQlD,KAAK,CAAC,8BAA8BA;QAC5C2H,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAAmC;IACnE;AACF,EAAC;AAED;;;;;CAKC,GACD,eAAeyJ,cACbpJ,GAAqB,EACrBsH,GAAsB,EACtBrH,KAA2B;QAUtBD;IARL,IAAI,CAACC,SAASA,MAAMI,KAAK,KAAK,OAAO;QACnCiH,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YACnB9H,OAAO;QACT;QACA;IACF;IACA,MAAMwJ,UAAU;WACX,IAAIE,IACL,EAACrJ,YAAAA,IAAIH,IAAI,qBAARG,UAAUmJ,OAAO,EAAexG,GAAG,CAAC,CAACyE,KAAOlG,OAAOkG,aAAAA,KAAM,IAAIhF,IAAI,IAAIkH,MAAM,CAAClE;KAEhF;IACD,IAAI,CAAC+D,QAAQ5H,MAAM,IAAI4H,QAAQ5H,MAAM,GAAGtC,oBAAoB;QAC1DqI,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YACnB9H,OAAO,CAAC,uBAAuB,EAAEV,mBAAmB,iBAAiB,CAAC;QACxE;QACA;IACF;IAEA,IAAI;QACF,MAAMqD,SAAS,MAAMvC,mBAAmBC,KAAKC;QAC7C,IAAIqC,OAAO1C,EAAE,KAAK,OAAO;YACvB0H,IAAI5H,MAAM,CAAC4C,OAAO5C,MAAM,EAAE+H,IAAI,CAACnF,OAAOzC,IAAI;YAC1C;QACF;QACA,MAAMgF,QAAQH,gBAAgBpC,OAAO/B,KAAK;QAC1C,MAAM8H,UAAoC,EAAE;QAC5C,KAAK,MAAMnC,UAAUiD,QAAS;YAC5Bd,QAAQD,IAAI,CAAC,MAAME,aAAahG,QAAQrC,OAAO4E,OAAOqB;QACxD;QACA,MAAMrG,OAAiC;YAAED,IAAI;YAAMyI;QAAQ;QAC3Df,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC5H;IACvB,EAAE,OAAOF,OAAO;QACdkD,QAAQlD,KAAK,CAAC,oCAAoCA;QAClD2H,IAAI5H,MAAM,CAAC,KAAK+H,IAAI,CAAC;YAAE9H,OAAO;QAAoC;IACpE;AACF"}
@@ -82,8 +82,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
82
82
  * not undo the win: the deal is already closed, and the response reports
83
83
  * `customer: null` rather than a 500 for a stage change that did happen.
84
84
  */ import { CRM_COLLECTIONS, dealStageById, hostRoleFor, hostScopeToken, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
85
- import { consentGroupForSite, firebaseAdmin, floorContactLifecycleStage, getOrgForHost, logOrgActivity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
85
+ import { consentGroupForSite, firebaseAdmin, getOrgForHost, logOrgActivity, memberHasOrgPermission, resolveOrgMembership } from "@aglyn/tenant-data-admin";
86
86
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
87
+ import { floorContactLifecycleStage } from "./server/contact-lifecycle-floor.js";
87
88
  import { emitHostEvent } from "@aglyn/tenant-runtime";
88
89
  import { FieldValue } from "firebase-admin/firestore";
89
90
  import { closingStage, dealEventName, dealEventPayload } from "./model/deal-board-model.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server-deal-stage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/deal-stage` — the one writer of a deal's stage (AGL-2598).\n *\n * ## Why a route, when the rules would let the browser do it\n *\n * A deal's title, amount and owner are edited client-direct against the\n * Firestore rules, and nothing about a stage change needs a server to be\n * SAFE. What it needs a server for is to be HEARD: a move between stages, a\n * win and a loss are the three moments an automation wants — \"when a deal is\n * won, email the owner\", \"when one reaches negotiation, file a task\" — and a\n * workflow only fires on an event the tenant runtime emits. A stage written\n * by the browser would be a fact nothing could react to. So every surface\n * that moves a deal calls here, and this handler updates the document and\n * then emits `dealStageChanged`, `dealWon` or `dealLost` with a flat payload\n * a workflow filter can read.\n *\n * ## Who may call it\n *\n * Three gates, in the order the cheapest fails first:\n *\n * 1. A verified Firebase session — the console's `authorizedFetch` puts the\n * id token on every call.\n * 2. An admin or editor role ON THIS SITE, resolved by `hostRoleFor` from\n * the org member document, which is the same projection the rules read.\n * 3. The `data.manage` permission in the org — the key the whole CRM is\n * gated on, resolved through the custom-role and per-member layers by\n * `memberHasOrgPermission`, because a member document alone cannot say\n * what a custom role took away.\n *\n * And then the deal has to be VISIBLE to the site: its `visibleTo` must\n * intersect the scope tokens a contact captured on this site would carry.\n * The Admin SDK evaluates no rules, so without this check an editor of one\n * site in an agency could move another client's deals by id.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, dealId, … }` instead of a site: the org-level hub's board. The\n * caller is authorized by the ORG — an org-wide member holding\n * `data.manage`, the org hub's own gate — and no visibility check is made,\n * because an org-wide member reads every row the org holds. The event\n * still goes to the deal's own capturing site, which is where the\n * automations that listen for it live; a deal no site captured has none to\n * tell. And the act is logged in the org's feed, since there is no one\n * site's feed it happened in.\n *\n * ## What it writes\n *\n * `stageId`, `status` (from the target stage's `kind`, never from the body),\n * `stageChangedAtMs`, `closedAtMs` (set on a close, cleared on a reopen) and\n * `lostReason` (kept on a loss, removed on anything else). A move to the\n * stage the deal is already in writes nothing and emits nothing — an\n * automation must not fire twice because a card was dropped where it was.\n *\n * ## A won deal makes its contact a customer (AGL-2641)\n *\n * A win is the same fact as a purchase — the business has decided this\n * person bought — and an order already floors a contact's lifecycle stage\n * at `customer` on capture. So the transition INTO `won` applies the same\n * floor to the contact the deal names, in the same write path, behind no\n * setting: `floorContactLifecycleStage` fills an empty stage or advances an\n * earlier one inside the facet of the site the deal was made on, and never\n * moves anybody back — a customer stays a customer, an evangelist an\n * evangelist. When the stage did move, `contactStageChanged` is announced\n * after `dealWon`, so an automation keyed on \"became a customer\" hears it,\n * and the org's activity line says so. A failure to write the contact does\n * not undo the win: the deal is already closed, and the response reports\n * `customer: null` rather than a 500 for a stage change that did happen.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmDeal,\n type CrmPipeline,\n dealStageById,\n hostRoleFor,\n hostScopeToken,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n type ContactLifecycleFloor,\n firebaseAdmin,\n floorContactLifecycleStage,\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 { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n closingStage,\n dealEventName,\n dealEventPayload,\n} from './model/deal-board-model'\nimport { authorizeOrgCaller, readCrmRouteScope } from './server/org-caller'\nimport { crmSuiteRefusal } from './server/suite-gate'\n\n/** The most a lost reason may carry — a sentence or two, not a post-mortem. */\nexport const LOST_REASON_MAX = 500\n\nexport const crmDealStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const routeScope = readCrmRouteScope(req.body as Record<string, unknown>)\n const dealId = String(req.body?.dealId ?? '').trim()\n const stageId =\n typeof req.body?.stageId === 'string' ? req.body.stageId.trim() : ''\n const status = req.body?.status\n const closing = status === 'won' || status === 'lost' ? status : null\n const lostReason = String(req.body?.lostReason ?? '')\n .trim()\n .slice(0, LOST_REASON_MAX)\n if (!routeScope || !dealId) {\n return res.status(400).json({ error: 'Missing hostId or dealId' })\n }\n if (!stageId && !closing) {\n return res.status(400).json({\n error: 'Name a stageId to move to, or a status of won or lost.',\n })\n }\n\n /*\n * Who is calling, and the org whose deal it is. The site variant\n * resolves the org from the site and the caller's role on it; the org\n * variant is authorized by the org itself, and the site the body may\n * name beside it is not consulted — the deal's own site, read off the\n * document, is the one its event goes to.\n */\n let orgId: string\n let org: Record<string, unknown>\n let actor: { uid: string; email: string | null } | null = null\n // Who moved the deal, at either level, for the runs it sets off (AGL-3376).\n let eventActor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'data.manage',\n refusal:\n 'Moving a deal at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.',\n })\n if (caller.ok === false) {\n return res.status(caller.status).json({ error: caller.error })\n }\n orgId = caller.orgId\n org = caller.org as Record<string, unknown>\n actor = { uid: caller.uid, email: caller.email }\n eventActor = actor\n } else {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n let uid: string\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n uid = decoded.uid\n eventActor = {\n uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n }\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 res.status(401).json({ error: 'Unauthenticated' })\n console.error('[crm] deal-stage could not verify the caller', error)\n return res.status(500).json({ error: 'The sign-in could not be checked. Try again.' })\n }\n\n const owner = await getOrgForHost(routeScope.hostId).catch(() => null)\n if (!owner) {\n return res.status(404).json({\n error: 'This site has no organization, so it has no deals.',\n })\n }\n const membership = await resolveOrgMembership(uid, owner.orgId).catch(\n () => null,\n )\n const member = membership?.member\n const hostRole = hostRoleFor(member, routeScope.hostId)\n if (!member || (hostRole !== 'admin' && hostRole !== 'editor')) {\n return res.status(403).json({\n error: 'Moving a deal needs an admin or editor role on this site.',\n })\n }\n if (!(await memberHasOrgPermission(owner.orgId, member, 'data.manage'))) {\n return res.status(403).json({\n error: 'Moving a deal requires the \"Manage data\" permission.',\n })\n }\n orgId = owner.orgId\n org = owner.org as Record<string, unknown>\n }\n\n // Deals are the CRM suite's, at either level (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Moving a deal through its pipeline')\n if (suite) return res.status(suite.status).json(suite.body)\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const dealRef = orgRef.collection(CRM_COLLECTIONS.deals).doc(dealId)\n const dealSnapshot = await dealRef.get()\n if (!dealSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown deal' })\n }\n const deal = dealSnapshot.data() as CrmDeal\n\n if (routeScope.level === 'site') {\n const group = await consentGroupForSite(routeScope.hostId, org)\n const readable = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...group.hostIds.map(hostScopeToken),\n ])\n if (!(deal.visibleTo ?? []).some((token) => readable.has(token))) {\n return res.status(403).json({ error: 'This deal is not visible to this site.' })\n }\n }\n\n const pipelineSnapshot = await orgRef\n .collection(CRM_COLLECTIONS.pipelines)\n .doc(String(deal.pipelineId ?? ''))\n .get()\n if (!pipelineSnapshot.exists) {\n return res.status(409).json({\n error: \"This deal's pipeline no longer exists, so it has no stages to move to.\",\n })\n }\n const pipeline = pipelineSnapshot.data() as CrmPipeline\n const target = stageId\n ? dealStageById(pipeline, stageId)\n : closingStage(pipeline, closing as 'won' | 'lost')\n if (!target) {\n return res.status(400).json({\n error: stageId\n ? 'This pipeline has no such stage.'\n : `This pipeline has no ${closing} stage.`,\n })\n }\n\n const previousStageId = String(deal.stageId ?? '')\n const nextStatus = target.kind\n if (target.id === previousStageId && deal.status === nextStatus) {\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event: null,\n })\n }\n\n const nowMs = Date.now()\n await dealRef.update({\n stageId: target.id,\n status: nextStatus,\n stageChangedAtMs: nowMs,\n closedAtMs: nextStatus === 'open' ? null : nowMs,\n lostReason: nextStatus === 'lost' ? lostReason : FieldValue.delete(),\n updatedAt: new Date(),\n })\n\n const event = dealEventName(nextStatus)\n // The site whose automations hear the move: the mounted site, or at the\n // organization level the deal's own — a deal no site captured has none.\n const eventHostId =\n routeScope.level === 'site' ? routeScope.hostId : String(deal.hostId ?? '').trim()\n\n /*\n * The customer floor, on the transition into `won` alone: a move between\n * open stages, a loss and a reopen say nothing about whether the person\n * bought. The facet written is the site's the deal was made on — under\n * a site the mounted one, at the organization level the deal's own, and\n * for a deal no site captured the person's own capturing site.\n */\n let customer: ContactLifecycleFloor | null = null\n const contactId = String(deal.contactId ?? '').trim()\n if (nextStatus === 'won' && contactId) {\n customer = await floorContactLifecycleStage({\n contactRef: orgRef.collection('contacts').doc(contactId),\n org,\n hostId: eventHostId || null,\n floor: 'customer',\n }).catch((error: unknown) => {\n console.error('[crm] deal-stage: the customer floor failed', dealId, contactId, error)\n return null\n })\n }\n const becameCustomer = customer?.outcome === 'advanced'\n\n if (eventHostId) {\n await emitHostEvent(\n eventHostId,\n event,\n dealEventPayload(\n dealId,\n {\n ...deal,\n stageId: target.id,\n lostReason: nextStatus === 'lost' ? lostReason : undefined,\n },\n previousStageId,\n ),\n { actor: { kind: 'member', ...eventActor } },\n )\n }\n if (customer?.outcome === 'advanced') {\n // The stage change is the win's effect, announced after its cause, to\n // the site whose facet moved — the same site as the win's, unless no\n // site captured the deal and the person's own site holds the stage.\n await emitHostEvent(\n customer.hostId,\n 'contactStageChanged',\n {\n contactId: customer.contactId,\n email: customer.email,\n lifecycleStage: customer.lifecycleStage,\n previousStage: customer.previousStage,\n },\n { actor: { kind: 'member', ...eventActor } },\n )\n }\n if (actor) {\n await logOrgActivity(\n orgId,\n actor,\n nextStatus === 'won'\n ? becameCustomer\n ? 'Marked deal won — contact now a customer'\n : 'Marked deal won'\n : nextStatus === 'lost'\n ? 'Marked deal lost'\n : `Moved deal to ${target.name}`,\n { type: 'deal', id: dealId, name: String(deal.title ?? '') },\n )\n }\n\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event,\n /*\n * What the win did to the contact: the stage it now holds and whether\n * this call raised it. `null` on anything but a win, on a deal that\n * names no contact, and on a contact write that failed.\n */\n customer:\n customer && (customer.outcome === 'advanced' || customer.outcome === 'held')\n ? {\n contactId: customer.contactId,\n lifecycleStage: customer.lifecycleStage,\n advanced: customer.outcome === 'advanced',\n }\n : null,\n })\n}\n"],"names":["CRM_COLLECTIONS","dealStageById","hostRoleFor","hostScopeToken","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","floorContactLifecycleStage","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","emitHostEvent","FieldValue","closingStage","dealEventName","dealEventPayload","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","LOST_REASON_MAX","crmDealStageHandler","req","res","deal","method","setHeader","status","json","error","routeScope","body","dealId","String","trim","stageId","closing","lostReason","slice","orgId","org","actor","eventActor","level","caller","needs","refusal","ok","uid","email","authorization","headers","idToken","startsWith","length","decoded","app","auth","verifyIdToken","console","owner","hostId","catch","membership","member","hostRole","suite","firestore","orgRef","collection","doc","dealRef","deals","dealSnapshot","get","exists","data","group","readable","Set","hostIds","map","visibleTo","some","token","has","pipelineSnapshot","pipelines","pipelineId","pipeline","target","previousStageId","nextStatus","kind","id","event","nowMs","Date","now","update","stageChangedAtMs","closedAtMs","delete","updatedAt","eventHostId","customer","contactId","contactRef","floor","becameCustomer","outcome","undefined","lifecycleStage","previousStage","name","type","title","advanced"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server-deal-stage.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/deal-stage` — the one writer of a deal's stage (AGL-2598).\n *\n * ## Why a route, when the rules would let the browser do it\n *\n * A deal's title, amount and owner are edited client-direct against the\n * Firestore rules, and nothing about a stage change needs a server to be\n * SAFE. What it needs a server for is to be HEARD: a move between stages, a\n * win and a loss are the three moments an automation wants — \"when a deal is\n * won, email the owner\", \"when one reaches negotiation, file a task\" — and a\n * workflow only fires on an event the tenant runtime emits. A stage written\n * by the browser would be a fact nothing could react to. So every surface\n * that moves a deal calls here, and this handler updates the document and\n * then emits `dealStageChanged`, `dealWon` or `dealLost` with a flat payload\n * a workflow filter can read.\n *\n * ## Who may call it\n *\n * Three gates, in the order the cheapest fails first:\n *\n * 1. A verified Firebase session — the console's `authorizedFetch` puts the\n * id token on every call.\n * 2. An admin or editor role ON THIS SITE, resolved by `hostRoleFor` from\n * the org member document, which is the same projection the rules read.\n * 3. The `data.manage` permission in the org — the key the whole CRM is\n * gated on, resolved through the custom-role and per-member layers by\n * `memberHasOrgPermission`, because a member document alone cannot say\n * what a custom role took away.\n *\n * And then the deal has to be VISIBLE to the site: its `visibleTo` must\n * intersect the scope tokens a contact captured on this site would carry.\n * The Admin SDK evaluates no rules, so without this check an editor of one\n * site in an agency could move another client's deals by id.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, dealId, … }` instead of a site: the org-level hub's board. The\n * caller is authorized by the ORG — an org-wide member holding\n * `data.manage`, the org hub's own gate — and no visibility check is made,\n * because an org-wide member reads every row the org holds. The event\n * still goes to the deal's own capturing site, which is where the\n * automations that listen for it live; a deal no site captured has none to\n * tell. And the act is logged in the org's feed, since there is no one\n * site's feed it happened in.\n *\n * ## What it writes\n *\n * `stageId`, `status` (from the target stage's `kind`, never from the body),\n * `stageChangedAtMs`, `closedAtMs` (set on a close, cleared on a reopen) and\n * `lostReason` (kept on a loss, removed on anything else). A move to the\n * stage the deal is already in writes nothing and emits nothing — an\n * automation must not fire twice because a card was dropped where it was.\n *\n * ## A won deal makes its contact a customer (AGL-2641)\n *\n * A win is the same fact as a purchase — the business has decided this\n * person bought — and an order already floors a contact's lifecycle stage\n * at `customer` on capture. So the transition INTO `won` applies the same\n * floor to the contact the deal names, in the same write path, behind no\n * setting: `floorContactLifecycleStage` fills an empty stage or advances an\n * earlier one inside the facet of the site the deal was made on, and never\n * moves anybody back — a customer stays a customer, an evangelist an\n * evangelist. When the stage did move, `contactStageChanged` is announced\n * after `dealWon`, so an automation keyed on \"became a customer\" hears it,\n * and the org's activity line says so. A failure to write the contact does\n * not undo the win: the deal is already closed, and the response reports\n * `customer: null` rather than a 500 for a stage change that did happen.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmDeal,\n type CrmPipeline,\n dealStageById,\n hostRoleFor,\n hostScopeToken,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\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 {\n type ContactLifecycleFloor,\n floorContactLifecycleStage,\n} from './server/contact-lifecycle-floor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n closingStage,\n dealEventName,\n dealEventPayload,\n} from './model/deal-board-model'\nimport { authorizeOrgCaller, readCrmRouteScope } from './server/org-caller'\nimport { crmSuiteRefusal } from './server/suite-gate'\n\n/** The most a lost reason may carry — a sentence or two, not a post-mortem. */\nexport const LOST_REASON_MAX = 500\n\nexport const crmDealStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const routeScope = readCrmRouteScope(req.body as Record<string, unknown>)\n const dealId = String(req.body?.dealId ?? '').trim()\n const stageId =\n typeof req.body?.stageId === 'string' ? req.body.stageId.trim() : ''\n const status = req.body?.status\n const closing = status === 'won' || status === 'lost' ? status : null\n const lostReason = String(req.body?.lostReason ?? '')\n .trim()\n .slice(0, LOST_REASON_MAX)\n if (!routeScope || !dealId) {\n return res.status(400).json({ error: 'Missing hostId or dealId' })\n }\n if (!stageId && !closing) {\n return res.status(400).json({\n error: 'Name a stageId to move to, or a status of won or lost.',\n })\n }\n\n /*\n * Who is calling, and the org whose deal it is. The site variant\n * resolves the org from the site and the caller's role on it; the org\n * variant is authorized by the org itself, and the site the body may\n * name beside it is not consulted — the deal's own site, read off the\n * document, is the one its event goes to.\n */\n let orgId: string\n let org: Record<string, unknown>\n let actor: { uid: string; email: string | null } | null = null\n // Who moved the deal, at either level, for the runs it sets off (AGL-3376).\n let eventActor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'data.manage',\n refusal:\n 'Moving a deal at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.',\n })\n if (caller.ok === false) {\n return res.status(caller.status).json({ error: caller.error })\n }\n orgId = caller.orgId\n org = caller.org as Record<string, unknown>\n actor = { uid: caller.uid, email: caller.email }\n eventActor = actor\n } else {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n let uid: string\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n uid = decoded.uid\n eventActor = {\n uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n }\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 res.status(401).json({ error: 'Unauthenticated' })\n console.error('[crm] deal-stage could not verify the caller', error)\n return res.status(500).json({ error: 'The sign-in could not be checked. Try again.' })\n }\n\n const owner = await getOrgForHost(routeScope.hostId).catch(() => null)\n if (!owner) {\n return res.status(404).json({\n error: 'This site has no organization, so it has no deals.',\n })\n }\n const membership = await resolveOrgMembership(uid, owner.orgId).catch(\n () => null,\n )\n const member = membership?.member\n const hostRole = hostRoleFor(member, routeScope.hostId)\n if (!member || (hostRole !== 'admin' && hostRole !== 'editor')) {\n return res.status(403).json({\n error: 'Moving a deal needs an admin or editor role on this site.',\n })\n }\n if (!(await memberHasOrgPermission(owner.orgId, member, 'data.manage'))) {\n return res.status(403).json({\n error: 'Moving a deal requires the \"Manage data\" permission.',\n })\n }\n orgId = owner.orgId\n org = owner.org as Record<string, unknown>\n }\n\n // Deals are the CRM suite's, at either level (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Moving a deal through its pipeline')\n if (suite) return res.status(suite.status).json(suite.body)\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const dealRef = orgRef.collection(CRM_COLLECTIONS.deals).doc(dealId)\n const dealSnapshot = await dealRef.get()\n if (!dealSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown deal' })\n }\n const deal = dealSnapshot.data() as CrmDeal\n\n if (routeScope.level === 'site') {\n const group = await consentGroupForSite(routeScope.hostId, org)\n const readable = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...group.hostIds.map(hostScopeToken),\n ])\n if (!(deal.visibleTo ?? []).some((token) => readable.has(token))) {\n return res.status(403).json({ error: 'This deal is not visible to this site.' })\n }\n }\n\n const pipelineSnapshot = await orgRef\n .collection(CRM_COLLECTIONS.pipelines)\n .doc(String(deal.pipelineId ?? ''))\n .get()\n if (!pipelineSnapshot.exists) {\n return res.status(409).json({\n error: \"This deal's pipeline no longer exists, so it has no stages to move to.\",\n })\n }\n const pipeline = pipelineSnapshot.data() as CrmPipeline\n const target = stageId\n ? dealStageById(pipeline, stageId)\n : closingStage(pipeline, closing as 'won' | 'lost')\n if (!target) {\n return res.status(400).json({\n error: stageId\n ? 'This pipeline has no such stage.'\n : `This pipeline has no ${closing} stage.`,\n })\n }\n\n const previousStageId = String(deal.stageId ?? '')\n const nextStatus = target.kind\n if (target.id === previousStageId && deal.status === nextStatus) {\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event: null,\n })\n }\n\n const nowMs = Date.now()\n await dealRef.update({\n stageId: target.id,\n status: nextStatus,\n stageChangedAtMs: nowMs,\n closedAtMs: nextStatus === 'open' ? null : nowMs,\n lostReason: nextStatus === 'lost' ? lostReason : FieldValue.delete(),\n updatedAt: new Date(),\n })\n\n const event = dealEventName(nextStatus)\n // The site whose automations hear the move: the mounted site, or at the\n // organization level the deal's own — a deal no site captured has none.\n const eventHostId =\n routeScope.level === 'site' ? routeScope.hostId : String(deal.hostId ?? '').trim()\n\n /*\n * The customer floor, on the transition into `won` alone: a move between\n * open stages, a loss and a reopen say nothing about whether the person\n * bought. The facet written is the site's the deal was made on — under\n * a site the mounted one, at the organization level the deal's own, and\n * for a deal no site captured the person's own capturing site.\n */\n let customer: ContactLifecycleFloor | null = null\n const contactId = String(deal.contactId ?? '').trim()\n if (nextStatus === 'won' && contactId) {\n customer = await floorContactLifecycleStage({\n contactRef: orgRef.collection('contacts').doc(contactId),\n org,\n hostId: eventHostId || null,\n floor: 'customer',\n }).catch((error: unknown) => {\n console.error('[crm] deal-stage: the customer floor failed', dealId, contactId, error)\n return null\n })\n }\n const becameCustomer = customer?.outcome === 'advanced'\n\n if (eventHostId) {\n await emitHostEvent(\n eventHostId,\n event,\n dealEventPayload(\n dealId,\n {\n ...deal,\n stageId: target.id,\n lostReason: nextStatus === 'lost' ? lostReason : undefined,\n },\n previousStageId,\n ),\n { actor: { kind: 'member', ...eventActor } },\n )\n }\n if (customer?.outcome === 'advanced') {\n // The stage change is the win's effect, announced after its cause, to\n // the site whose facet moved — the same site as the win's, unless no\n // site captured the deal and the person's own site holds the stage.\n await emitHostEvent(\n customer.hostId,\n 'contactStageChanged',\n {\n contactId: customer.contactId,\n email: customer.email,\n lifecycleStage: customer.lifecycleStage,\n previousStage: customer.previousStage,\n },\n { actor: { kind: 'member', ...eventActor } },\n )\n }\n if (actor) {\n await logOrgActivity(\n orgId,\n actor,\n nextStatus === 'won'\n ? becameCustomer\n ? 'Marked deal won — contact now a customer'\n : 'Marked deal won'\n : nextStatus === 'lost'\n ? 'Marked deal lost'\n : `Moved deal to ${target.name}`,\n { type: 'deal', id: dealId, name: String(deal.title ?? '') },\n )\n }\n\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event,\n /*\n * What the win did to the contact: the stage it now holds and whether\n * this call raised it. `null` on anything but a win, on a deal that\n * names no contact, and on a contact write that failed.\n */\n customer:\n customer && (customer.outcome === 'advanced' || customer.outcome === 'held')\n ? {\n contactId: customer.contactId,\n lifecycleStage: customer.lifecycleStage,\n advanced: customer.outcome === 'advanced',\n }\n : null,\n })\n}\n"],"names":["CRM_COLLECTIONS","dealStageById","hostRoleFor","hostScopeToken","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","floorContactLifecycleStage","emitHostEvent","FieldValue","closingStage","dealEventName","dealEventPayload","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","LOST_REASON_MAX","crmDealStageHandler","req","res","deal","method","setHeader","status","json","error","routeScope","body","dealId","String","trim","stageId","closing","lostReason","slice","orgId","org","actor","eventActor","level","caller","needs","refusal","ok","uid","email","authorization","headers","idToken","startsWith","length","decoded","app","auth","verifyIdToken","console","owner","hostId","catch","membership","member","hostRole","suite","firestore","orgRef","collection","doc","dealRef","deals","dealSnapshot","get","exists","data","group","readable","Set","hostIds","map","visibleTo","some","token","has","pipelineSnapshot","pipelines","pipelineId","pipeline","target","previousStageId","nextStatus","kind","id","event","nowMs","Date","now","update","stageChangedAtMs","closedAtMs","delete","updatedAt","eventHostId","customer","contactId","contactRef","floor","becameCustomer","outcome","undefined","lifecycleStage","previousStage","name","type","title","advanced"],"mappings":"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"}
@@ -46,8 +46,10 @@
46
46
  * holder's rows and the audience band are judgments the browser cannot make.
47
47
  * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:
48
48
  * the rules cannot tell one field of a holder's facet from another, so they
49
- * leave a client nothing there but letting a holder go, and the plan is
50
- * asked here about the fields a save carries.
49
+ * leave a client nothing there, and the plan is asked here about the fields
50
+ * a save carries. `crm/contact-remove` (AGL-3338) is a holder letting a
51
+ * contact go: the refusals the document holds are kept, which a client
52
+ * write could not be trusted to do.
51
53
  */
52
54
  import { type PluginApiHandler } from '@aglyn/aglyn/server';
53
55
  /**
@@ -148,3 +150,4 @@ export declare const CONTACT_BAND_FULL_MESSAGE: string;
148
150
  export declare const crmContactsCreateHandler: PluginApiHandler;
149
151
  /** Console API registration, named in `plugins.config.json` as `consoleApi`. */
150
152
  export declare function registerCrmConsoleApi(): void;
153
+ export type { crmPlanEntitlements } from './plan-entitlements';
package/src/lib/server.js CHANGED
@@ -46,8 +46,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
46
46
  * holder's rows and the audience band are judgments the browser cannot make.
47
47
  * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:
48
48
  * the rules cannot tell one field of a holder's facet from another, so they
49
- * leave a client nothing there but letting a holder go, and the plan is
50
- * asked here about the fields a save carries.
49
+ * leave a client nothing there, and the plan is asked here about the fields
50
+ * a save carries. `crm/contact-remove` (AGL-3338) is a holder letting a
51
+ * contact go: the refusals the document holds are kept, which a client
52
+ * write could not be trusted to do.
51
53
  */ import { CONTACT_ERASED_MESSAGE, contactFacetPath, CRM_COLLECTIONS, crmReadTokens, isContactLifecycleStage, isOrgWideMember, normalizeAddress, normalizeContactEmail, normalizePhone, readContactFacet, registerPluginApiRoute, visibleToHost } from "@aglyn/aglyn/server";
52
54
  import { consentGroupForSite, firebaseAdmin, getOrgForHost, logHostActivity, memberHasOrgPermission, orgDataCollectionForHost, resolveOrgMembership, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
53
55
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
@@ -82,8 +84,11 @@ import { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from "./server/erase-pe
82
84
  import { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from "./server/org-activity.js";
83
85
  import { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from "./server/inbound-address.js";
84
86
  import { crmCompanyDeleteHandler } from "./server/company-delete.js";
87
+ import { crmSharingHandler } from "./server/crm-sharing.js";
88
+ import { CRM_SHARING_ROUTE } from "./model/crm-sharing.js";
85
89
  import { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from "./server/contact-profile.js";
86
90
  import { crmContactUpdateHandler } from "./server/contact-update.js";
91
+ import { crmContactRemoveHandler } from "./server/contact-remove.js";
87
92
  import { CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE, crmEmailTemplateDuplicateHandler } from "./server/email-template-duplicate.js";
88
93
  import { registerCrmRecordFactsReaders } from "./server/record-facts.js";
89
94
  import { crmSuiteRefusal } from "./server/suite-gate.js";
@@ -577,6 +582,7 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
577
582
  // Every other console write to a contact's facets (AGL-2804): the rules
578
583
  // cannot tell one facet field from another, so the plan is asked here.
579
584
  registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler);
585
+ registerPluginApiRoute(CRM_API_ROUTES.contactRemove, crmContactRemoveHandler);
580
586
  // A company's contacts unlinked, then the company (AGL-2804): the unlink
581
587
  // clears a facet, which is the server's to write.
582
588
  registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler);
@@ -589,6 +595,10 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
589
595
  // What a client-direct write the browser cannot follow up asks for: the
590
596
  // fields the CRM lists query, restamped from the record (AGL-3321).
591
597
  registerPluginApiRoute(CRM_LIST_FIELDS_ROUTE, crmListFieldsHandler);
598
+ // Sharing records with other sites (AGL-3336): a manager's share and
599
+ // unshare, the org's sharing rules and their recompute, and the rules'
600
+ // re-evaluation a client-direct write owes.
601
+ registerPluginApiRoute(CRM_SHARING_ROUTE, crmSharingHandler);
592
602
  // A lead source value renamed or deleted (AGL-3298): the list and every
593
603
  // lead and contact holding the old label, in one request.
594
604
  registerPluginApiRoute(CRM_LEAD_SOURCE_VALUES_ROUTE, crmLeadSourceValuesHandler);
@@ -654,6 +664,41 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
654
664
  // and rotated here, behind the CRM's own gate, and the org document that
655
665
  // carries it is closed to every client.
656
666
  registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler);
667
+ /*
668
+ * The three doors a MACHINE calls (AGL-3080), at the paths they have
669
+ * always answered on: the scheduler's daily digest and hourly task
670
+ * reminders, on the cron secret, and the mail provider's capture webhook,
671
+ * on its signature. None names a site or a member, so each is registered
672
+ * as a machine's route — the dispatcher leaves the plugin's release and
673
+ * enablement gates and its write limit to it — and each judges the CRM's
674
+ * plan and flag for every organization it resolves. Loaded with the
675
+ * first call, not with the surface.
676
+ */ registerPluginApiRoute(CRM_API_ROUTES.dailyDigest, {
677
+ web: async (request)=>(await import("./server/daily-digest-route.js")).crmDailyDigestRoute(request)
678
+ }, {
679
+ machine: true
680
+ });
681
+ registerPluginApiRoute(CRM_API_ROUTES.taskReminders, {
682
+ web: async (request)=>(await import("./server/task-reminders-route.js")).crmTaskRemindersRoute(request)
683
+ }, {
684
+ machine: true
685
+ });
686
+ registerPluginApiRoute(CRM_API_ROUTES.inbound, {
687
+ web: async (request)=>(await import("./server/inbound-route.js")).crmInboundRoute(request)
688
+ }, {
689
+ machine: true
690
+ });
691
+ /*
692
+ * The whole-collection export (AGL-2662). A portability route: the people
693
+ * files — contacts and leads — are what a workspace is owed on every plan
694
+ * and whether or not the CRM is switched on for it, so the dispatcher
695
+ * leaves its release and enablement gates to the route, which asks both
696
+ * of the CRM's own records. Loaded with the first export.
697
+ */ registerPluginApiRoute(CRM_API_ROUTES.export, {
698
+ web: async (request)=>(await import("./server/export-route.js")).crmExportRoute(request)
699
+ }, {
700
+ portability: true
701
+ });
657
702
  registerPluginApiRoute(CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE, crmEmailTemplateDuplicateHandler);
658
703
  // The facts of a contact, company, deal or lead, and an import's field
659
704
  // catalog, for another plugin in this process (AGL-2917): read on the core's