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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/package.json +14 -13
  2. package/src/index.d.ts +1 -0
  3. package/src/index.js.map +1 -1
  4. package/src/lib/components/companies-bulk-bar.js +12 -4
  5. package/src/lib/components/companies-bulk-bar.js.map +1 -1
  6. package/src/lib/components/company-detail-page.js +11 -0
  7. package/src/lib/components/company-detail-page.js.map +1 -1
  8. package/src/lib/components/company-edit-drawer.js +12 -2
  9. package/src/lib/components/company-edit-drawer.js.map +1 -1
  10. package/src/lib/components/company-import-drawer.d.ts +1 -1
  11. package/src/lib/components/company-picker.js +10 -3
  12. package/src/lib/components/company-picker.js.map +1 -1
  13. package/src/lib/components/contact-detail-page.d.ts +4 -4
  14. package/src/lib/components/contact-detail-page.js +50 -53
  15. package/src/lib/components/contact-detail-page.js.map +1 -1
  16. package/src/lib/components/contact-list-columns.d.ts +3 -0
  17. package/src/lib/components/contact-list-columns.js +21 -5
  18. package/src/lib/components/contact-list-columns.js.map +1 -1
  19. package/src/lib/components/contact-merge-dialog.js +2 -1
  20. package/src/lib/components/contact-merge-dialog.js.map +1 -1
  21. package/src/lib/components/contact-timeline-card.js +4 -3
  22. package/src/lib/components/contact-timeline-card.js.map +1 -1
  23. package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
  24. package/src/lib/components/contacts-bulk-bar.js +24 -51
  25. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  26. package/src/lib/components/contacts-section.js +9 -3
  27. package/src/lib/components/contacts-section.js.map +1 -1
  28. package/src/lib/components/crm-export-all-button.d.ts +1 -1
  29. package/src/lib/components/crm-export-all-button.js.map +1 -1
  30. package/src/lib/components/crm-send-email-dialog.js +9 -6
  31. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  32. package/src/lib/components/csv-import-drawer.js +2 -2
  33. package/src/lib/components/csv-import-drawer.js.map +1 -1
  34. package/src/lib/components/deal-detail-page.js +18 -2
  35. package/src/lib/components/deal-detail-page.js.map +1 -1
  36. package/src/lib/components/deal-edit-drawer.js +10 -0
  37. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  38. package/src/lib/components/deals-bulk-bar.js +13 -5
  39. package/src/lib/components/deals-bulk-bar.js.map +1 -1
  40. package/src/lib/components/lead-campaigns-card.js +6 -0
  41. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  42. package/src/lib/components/lead-detail-page.js +25 -5
  43. package/src/lib/components/lead-detail-page.js.map +1 -1
  44. package/src/lib/components/lead-properties-card.js +6 -0
  45. package/src/lib/components/lead-properties-card.js.map +1 -1
  46. package/src/lib/components/lead-surfaces-note.js +4 -5
  47. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  48. package/src/lib/components/lead-unqualify-dialog.js +6 -0
  49. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  50. package/src/lib/components/leads-bulk-bar.js +28 -3
  51. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  52. package/src/lib/components/leads-section.js +36 -7
  53. package/src/lib/components/leads-section.js.map +1 -1
  54. package/src/lib/components/org-lead-surfaces-note.js +6 -2
  55. package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
  56. package/src/lib/components/recipes-card.js +9 -4
  57. package/src/lib/components/recipes-card.js.map +1 -1
  58. package/src/lib/components/record-sharing-card.d.ts +57 -0
  59. package/src/lib/components/record-sharing-card.js +244 -0
  60. package/src/lib/components/record-sharing-card.js.map +1 -0
  61. package/src/lib/components/settings-section.js +7 -0
  62. package/src/lib/components/settings-section.js.map +1 -1
  63. package/src/lib/components/share-records-dialog.d.ts +28 -0
  64. package/src/lib/components/share-records-dialog.js +184 -0
  65. package/src/lib/components/share-records-dialog.js.map +1 -0
  66. package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
  67. package/src/lib/components/sharing-rule-drawer.js +374 -0
  68. package/src/lib/components/sharing-rule-drawer.js.map +1 -0
  69. package/src/lib/components/sharing-rules-card.d.ts +24 -0
  70. package/src/lib/components/sharing-rules-card.js +287 -0
  71. package/src/lib/components/sharing-rules-card.js.map +1 -0
  72. package/src/lib/components/use-crm-api.d.ts +1 -1
  73. package/src/lib/components/use-crm-api.js.map +1 -1
  74. package/src/lib/components/use-site-route-context.d.ts +25 -0
  75. package/src/lib/components/use-site-route-context.js +58 -0
  76. package/src/lib/components/use-site-route-context.js.map +1 -0
  77. package/src/lib/constants/api-routes.d.ts +31 -0
  78. package/src/lib/constants/api-routes.js +27 -1
  79. package/src/lib/constants/api-routes.js.map +1 -1
  80. package/src/lib/declarations.console-server.d.ts +37 -0
  81. package/src/lib/declarations.console-server.js +103 -0
  82. package/src/lib/declarations.console-server.js.map +1 -0
  83. package/src/lib/declarations.server.d.ts +6 -0
  84. package/src/lib/declarations.server.js +33 -0
  85. package/src/lib/declarations.server.js.map +1 -1
  86. package/src/lib/hooks/use-contact-remove.d.ts +35 -0
  87. package/src/lib/hooks/use-contact-remove.js +77 -0
  88. package/src/lib/hooks/use-contact-remove.js.map +1 -0
  89. package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
  90. package/src/lib/hooks/use-crm-sharing.js +109 -0
  91. package/src/lib/hooks/use-crm-sharing.js.map +1 -0
  92. package/src/lib/model/companies-csv.d.ts +1 -1
  93. package/src/lib/model/companies-csv.js +2 -2
  94. package/src/lib/model/companies-csv.js.map +1 -1
  95. package/src/lib/model/contact-holder.d.ts +68 -0
  96. package/src/lib/model/contact-holder.js +129 -0
  97. package/src/lib/model/contact-holder.js.map +1 -0
  98. package/src/lib/model/contact-interaction-links.d.ts +46 -0
  99. package/src/lib/model/contact-interaction-links.js +65 -0
  100. package/src/lib/model/contact-interaction-links.js.map +1 -0
  101. package/src/lib/model/contact-merge.d.ts +136 -0
  102. package/src/lib/model/contact-merge.js +412 -0
  103. package/src/lib/model/contact-merge.js.map +1 -0
  104. package/src/lib/model/contact-record.d.ts +7 -1
  105. package/src/lib/model/contact-record.js +6 -5
  106. package/src/lib/model/contact-record.js.map +1 -1
  107. package/src/lib/model/contact-remove.d.ts +48 -0
  108. package/src/lib/model/contact-remove.js +22 -0
  109. package/src/lib/model/contact-remove.js.map +1 -0
  110. package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
  111. package/src/lib/model/contacts-bulk-writes.js +2 -82
  112. package/src/lib/model/contacts-bulk-writes.js.map +1 -1
  113. package/src/lib/model/contacts-csv.d.ts +2 -2
  114. package/src/lib/model/contacts-csv.js +2 -2
  115. package/src/lib/model/contacts-csv.js.map +1 -1
  116. package/src/lib/model/crm-csv.d.ts +239 -0
  117. package/src/lib/model/crm-csv.js +467 -0
  118. package/src/lib/model/crm-csv.js.map +1 -0
  119. package/src/lib/model/crm-digest.d.ts +185 -0
  120. package/src/lib/model/crm-digest.js +272 -0
  121. package/src/lib/model/crm-digest.js.map +1 -0
  122. package/src/lib/model/crm-import.d.ts +7 -1
  123. package/src/lib/model/crm-import.js +2 -1
  124. package/src/lib/model/crm-import.js.map +1 -1
  125. package/src/lib/model/crm-record-routes.d.ts +5 -0
  126. package/src/lib/model/crm-record-routes.js +7 -0
  127. package/src/lib/model/crm-record-routes.js.map +1 -1
  128. package/src/lib/model/crm-routes.d.ts +6 -5
  129. package/src/lib/model/crm-routes.js +7 -7
  130. package/src/lib/model/crm-routes.js.map +1 -1
  131. package/src/lib/model/crm-search-sources.d.ts +15 -0
  132. package/src/lib/model/crm-search-sources.js +162 -0
  133. package/src/lib/model/crm-search-sources.js.map +1 -0
  134. package/src/lib/model/crm-sharing-api.d.ts +32 -0
  135. package/src/lib/model/crm-sharing-api.js +59 -0
  136. package/src/lib/model/crm-sharing-api.js.map +1 -0
  137. package/src/lib/model/crm-sharing.d.ts +333 -0
  138. package/src/lib/model/crm-sharing.js +603 -0
  139. package/src/lib/model/crm-sharing.js.map +1 -0
  140. package/src/lib/model/deals-csv.d.ts +1 -1
  141. package/src/lib/model/deals-csv.js +4 -5
  142. package/src/lib/model/deals-csv.js.map +1 -1
  143. package/src/lib/model/lead-filters.d.ts +1 -1
  144. package/src/lib/model/leads-csv.d.ts +1 -1
  145. package/src/lib/model/leads-csv.js +1 -1
  146. package/src/lib/model/leads-csv.js.map +1 -1
  147. package/src/lib/model/tasks-csv.d.ts +1 -1
  148. package/src/lib/model/tasks-csv.js +1 -1
  149. package/src/lib/model/tasks-csv.js.map +1 -1
  150. package/src/lib/plan-entitlements.d.ts +75 -0
  151. package/src/lib/plan-entitlements.js +56 -0
  152. package/src/lib/plan-entitlements.js.map +1 -0
  153. package/src/lib/plugin.js +3 -0
  154. package/src/lib/plugin.js.map +1 -1
  155. package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
  156. package/src/lib/server/api-v1/contacts-merge.js +120 -0
  157. package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
  158. package/src/lib/server/api-v1/contacts.d.ts +18 -0
  159. package/src/lib/server/api-v1/contacts.js +1076 -0
  160. package/src/lib/server/api-v1/contacts.js.map +1 -0
  161. package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
  162. package/src/lib/server/api-v1/crm-activities.js +269 -0
  163. package/src/lib/server/api-v1/crm-activities.js.map +1 -0
  164. package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
  165. package/src/lib/server/api-v1/crm-companies.js +391 -0
  166. package/src/lib/server/api-v1/crm-companies.js.map +1 -0
  167. package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
  168. package/src/lib/server/api-v1/crm-deals.js +631 -0
  169. package/src/lib/server/api-v1/crm-deals.js.map +1 -0
  170. package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
  171. package/src/lib/server/api-v1/crm-email-templates.js +336 -0
  172. package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
  173. package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
  174. package/src/lib/server/api-v1/crm-leads.js +832 -0
  175. package/src/lib/server/api-v1/crm-leads.js.map +1 -0
  176. package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
  177. package/src/lib/server/api-v1/crm-pipelines.js +146 -0
  178. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
  179. package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
  180. package/src/lib/server/api-v1/crm-shared.js +436 -0
  181. package/src/lib/server/api-v1/crm-shared.js.map +1 -0
  182. package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
  183. package/src/lib/server/api-v1/crm-tasks.js +398 -0
  184. package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
  185. package/src/lib/server/api-v1/openapi.d.ts +29 -0
  186. package/src/lib/server/api-v1/openapi.js +985 -0
  187. package/src/lib/server/api-v1/openapi.js.map +1 -0
  188. package/src/lib/server/api-v1/usage.d.ts +26 -0
  189. package/src/lib/server/api-v1/usage.js +73 -0
  190. package/src/lib/server/api-v1/usage.js.map +1 -0
  191. package/src/lib/server/consent-group-participant.js +1 -1
  192. package/src/lib/server/consent-group-participant.js.map +1 -1
  193. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  194. package/src/lib/server/contact-lifecycle-floor.js +91 -0
  195. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  196. package/src/lib/server/contact-remove.d.ts +75 -0
  197. package/src/lib/server/contact-remove.js +244 -0
  198. package/src/lib/server/contact-remove.js.map +1 -0
  199. package/src/lib/server/contact-update.js +2 -1
  200. package/src/lib/server/contact-update.js.map +1 -1
  201. package/src/lib/server/contacts-import.d.ts +47 -11
  202. package/src/lib/server/contacts-import.js +171 -71
  203. package/src/lib/server/contacts-import.js.map +1 -1
  204. package/src/lib/server/contacts-merge.d.ts +1 -1
  205. package/src/lib/server/contacts-merge.js +3 -2
  206. package/src/lib/server/contacts-merge.js.map +1 -1
  207. package/src/lib/server/convert-host-lead.d.ts +189 -0
  208. package/src/lib/server/convert-host-lead.js +518 -0
  209. package/src/lib/server/convert-host-lead.js.map +1 -0
  210. package/src/lib/server/crm-sharing.d.ts +108 -0
  211. package/src/lib/server/crm-sharing.js +790 -0
  212. package/src/lib/server/crm-sharing.js.map +1 -0
  213. package/src/lib/server/daily-digest-route.d.ts +107 -0
  214. package/src/lib/server/daily-digest-route.js +588 -0
  215. package/src/lib/server/daily-digest-route.js.map +1 -0
  216. package/src/lib/server/export-route.d.ts +60 -0
  217. package/src/lib/server/export-route.js +447 -0
  218. package/src/lib/server/export-route.js.map +1 -0
  219. package/src/lib/server/inbound-route.d.ts +74 -0
  220. package/src/lib/server/inbound-route.js +257 -0
  221. package/src/lib/server/inbound-route.js.map +1 -0
  222. package/src/lib/server/lead-convert.d.ts +1 -1
  223. package/src/lib/server/lead-convert.js +2 -2
  224. package/src/lib/server/lead-convert.js.map +1 -1
  225. package/src/lib/server/merge-contacts.d.ts +55 -0
  226. package/src/lib/server/merge-contacts.js +274 -0
  227. package/src/lib/server/merge-contacts.js.map +1 -0
  228. package/src/lib/server/pipeline-record-index.d.ts +36 -0
  229. package/src/lib/server/pipeline-record-index.js +87 -0
  230. package/src/lib/server/pipeline-record-index.js.map +1 -0
  231. package/src/lib/server/record-facts.js +2 -1
  232. package/src/lib/server/record-facts.js.map +1 -1
  233. package/src/lib/server/task-reminders-route.d.ts +82 -0
  234. package/src/lib/server/task-reminders-route.js +497 -0
  235. package/src/lib/server/task-reminders-route.js.map +1 -0
  236. package/src/lib/server-deal-stage.js +2 -1
  237. package/src/lib/server-deal-stage.js.map +1 -1
  238. package/src/lib/server.d.ts +5 -2
  239. package/src/lib/server.js +47 -2
  240. package/src/lib/server.js.map +1 -1
  241. package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
  242. package/src/lib/testing/stand-in-site-record-routes.js +55 -0
  243. package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
  244. package/src/lib/usage-axes.d.ts +35 -0
  245. package/src/lib/usage-axes.js +68 -0
  246. package/src/lib/usage-axes.js.map +1 -0
  247. package/src/lib/components/use-emails-hub-path.d.ts +0 -24
  248. package/src/lib/components/use-emails-hub-path.js +0 -59
  249. package/src/lib/components/use-emails-hub-path.js.map +0 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * A person's lifecycle stage, FLOORED at what just happened to them
18
+ * (AGL-2641).
19
+ *
20
+ * The capture doors floor a stage inside `upsertHostContact`: an order
21
+ * names `customer` as its `initialLifecycleStage`, and the door fills an
22
+ * empty stage or advances an earlier one and never moves anybody back. A
23
+ * won deal is the same fact as a purchase — the business has decided this
24
+ * person bought — but it arrives at a record that already exists, through a
25
+ * writer that is not a capture door: the deal-stage route and the REST
26
+ * deals resource. This is the door's rule for them, over a contact document
27
+ * they already hold a reference to.
28
+ *
29
+ * The write goes into ONE holder's facet, because a stage is one business's
30
+ * reading of a person and the row is shared by every site in the org. The
31
+ * holder is the site the deal was made on, resolved to its consent group
32
+ * the way every other facet writer resolves it; a deal that names no site
33
+ * falls back to the site that captured the person, which is the one
34
+ * holder a contact always has. A contact that names none either is left
35
+ * alone and the caller told, rather than written into a facet nobody reads.
36
+ *
37
+ * The write is skipped when it would change nothing — a customer stays a
38
+ * customer, an evangelist stays an evangelist, `other` is never overwritten
39
+ * — so a caller can announce `contactStageChanged` exactly when `advanced`
40
+ * says the stage moved, and an automation listening for the change never
41
+ * hears one that did not happen.
42
+ */ import { consentGroupForHost, advanceContactLifecycleStage, contactFacetPath, isContactLifecycleStage, readContactFacet } from "@aglyn/aglyn/server";
43
+ import { FieldValue } from "firebase-admin/firestore";
44
+ import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
45
+ export async function floorContactLifecycleStage(options) {
46
+ var _snapshot_data, _options_hostId, _data_hostId, _advanceContactLifecycleStage, _data_email;
47
+ const { contactRef, org, floor } = options;
48
+ const snapshot = await contactRef.get();
49
+ if (!snapshot.exists) return {
50
+ outcome: 'missing',
51
+ contactId: contactRef.id
52
+ };
53
+ const data = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
54
+ const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '').trim() || String((_data_hostId = data['hostId']) != null ? _data_hostId : '').trim();
55
+ if (!hostId) return {
56
+ outcome: 'unheld',
57
+ contactId: snapshot.id
58
+ };
59
+ const { groupId } = consentGroupForHost(org != null ? org : null, hostId);
60
+ const facet = readContactFacet(data, groupId);
61
+ const held = isContactLifecycleStage(facet.lifecycleStage) ? facet.lifecycleStage : undefined;
62
+ const lifecycleStage = (_advanceContactLifecycleStage = advanceContactLifecycleStage(held, floor)) != null ? _advanceContactLifecycleStage : floor;
63
+ const email = String((_data_email = data['email']) != null ? _data_email : '');
64
+ if (lifecycleStage === held) {
65
+ return {
66
+ outcome: 'held',
67
+ contactId: snapshot.id,
68
+ email,
69
+ hostId,
70
+ groupId,
71
+ lifecycleStage
72
+ };
73
+ }
74
+ await snapshot.ref.update({
75
+ [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,
76
+ updatedAt: FieldValue.serverTimestamp()
77
+ });
78
+ // The stage is what the Contacts list filters by (AGL-3321).
79
+ await restampCrmListFieldsAt(snapshot.ref, 'contacts');
80
+ return {
81
+ outcome: 'advanced',
82
+ contactId: snapshot.id,
83
+ email,
84
+ hostId,
85
+ groupId,
86
+ previousStage: held != null ? held : '',
87
+ lifecycleStage
88
+ };
89
+ }
90
+
91
+ //# sourceMappingURL=contact-lifecycle-floor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-lifecycle-floor.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 * A person's lifecycle stage, FLOORED at what just happened to them\n * (AGL-2641).\n *\n * The capture doors floor a stage inside `upsertHostContact`: an order\n * names `customer` as its `initialLifecycleStage`, and the door fills an\n * empty stage or advances an earlier one and never moves anybody back. A\n * won deal is the same fact as a purchase — the business has decided this\n * person bought — but it arrives at a record that already exists, through a\n * writer that is not a capture door: the deal-stage route and the REST\n * deals resource. This is the door's rule for them, over a contact document\n * they already hold a reference to.\n *\n * The write goes into ONE holder's facet, because a stage is one business's\n * reading of a person and the row is shared by every site in the org. The\n * holder is the site the deal was made on, resolved to its consent group\n * the way every other facet writer resolves it; a deal that names no site\n * falls back to the site that captured the person, which is the one\n * holder a contact always has. A contact that names none either is left\n * alone and the caller told, rather than written into a facet nobody reads.\n *\n * The write is skipped when it would change nothing — a customer stays a\n * customer, an evangelist stays an evangelist, `other` is never overwritten\n * — so a caller can announce `contactStageChanged` exactly when `advanced`\n * says the stage moved, and an automation listening for the change never\n * hears one that did not happen.\n */\n\nimport {\n consentGroupForHost,\n advanceContactLifecycleStage,\n contactFacetPath,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\n\nexport type ContactLifecycleFloor =\n | {\n /** The stage was raised to the floor. */\n outcome: 'advanced'\n contactId: string\n /** The address on the row, for the event a caller announces. */\n email: string\n /** The site whose facet was written. */\n hostId: string\n groupId: string\n /** The stage before the write, or `''` for a person who had none. */\n previousStage: ContactLifecycleStage | ''\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** The person already held the floor or a later stage; nothing was written. */\n outcome: 'held'\n contactId: string\n email: string\n hostId: string\n groupId: string\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** No such contact. */\n outcome: 'missing'\n contactId: string\n }\n | {\n /** Neither the caller nor the contact names a site, so there is no facet to write. */\n outcome: 'unheld'\n contactId: string\n }\n\nexport interface ContactLifecycleFloorOptions {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The org the contact belongs to, for the consent groups — the same\n * document every facet writer resolves the holder through.\n */\n org: Record<string, unknown> | null | undefined\n /**\n * The site the act happened on, whose facet takes the stage. `null` for a\n * record no site captured; the contact's own capturing site is used then.\n */\n hostId: string | null | undefined\n /** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */\n floor: ContactLifecycleStage\n}\n\nexport async function floorContactLifecycleStage(\n options: ContactLifecycleFloorOptions,\n): Promise<ContactLifecycleFloor> {\n const { contactRef, org, floor } = options\n const snapshot = await contactRef.get()\n if (!snapshot.exists) return { outcome: 'missing', contactId: contactRef.id }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const hostId = String(options.hostId ?? '').trim() || String(data['hostId'] ?? '').trim()\n if (!hostId) return { outcome: 'unheld', contactId: snapshot.id }\n\n const { groupId } = consentGroupForHost(org ?? null, hostId)\n const facet = readContactFacet(data, groupId)\n const held = isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : undefined\n const lifecycleStage = advanceContactLifecycleStage(held, floor) ?? floor\n const email = String(data['email'] ?? '')\n if (lifecycleStage === held) {\n return { outcome: 'held', contactId: snapshot.id, email, hostId, groupId, lifecycleStage }\n }\n await snapshot.ref.update({\n [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n return {\n outcome: 'advanced',\n contactId: snapshot.id,\n email,\n hostId,\n groupId,\n previousStage: held ?? '',\n lifecycleStage,\n }\n}\n"],"names":["consentGroupForHost","advanceContactLifecycleStage","contactFacetPath","isContactLifecycleStage","readContactFacet","FieldValue","restampCrmListFieldsAt","floorContactLifecycleStage","options","snapshot","data","contactRef","org","floor","get","exists","outcome","contactId","id","hostId","String","trim","groupId","facet","held","lifecycleStage","undefined","email","ref","update","updatedAt","serverTimestamp","previousStage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAEhBC,uBAAuB,EACvBC,gBAAgB,QACX,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,sBAAsB,QAAQ,8CAA6C;AAoDpF,OAAO,eAAeC,2BACpBC,OAAqC;QAKvBC,gBACQD,iBAAuCE,cAQtCT,+BACFS;IAbrB,MAAM,EAAEC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAE,GAAGL;IACnC,MAAMC,WAAW,MAAME,WAAWG,GAAG;IACrC,IAAI,CAACL,SAASM,MAAM,EAAE,OAAO;QAAEC,SAAS;QAAWC,WAAWN,WAAWO,EAAE;IAAC;IAC5E,MAAMR,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;IAClC,MAAMU,SAASC,QAAOZ,kBAAAA,QAAQW,MAAM,YAAdX,kBAAkB,IAAIa,IAAI,MAAMD,QAAOV,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB,IAAIW,IAAI;IACvF,IAAI,CAACF,QAAQ,OAAO;QAAEH,SAAS;QAAUC,WAAWR,SAASS,EAAE;IAAC;IAEhE,MAAM,EAAEI,OAAO,EAAE,GAAGtB,oBAAoBY,cAAAA,MAAO,MAAMO;IACrD,MAAMI,QAAQnB,iBAAiBM,MAAMY;IACrC,MAAME,OAAOrB,wBAAwBoB,MAAME,cAAc,IACrDF,MAAME,cAAc,GACpBC;IACJ,MAAMD,kBAAiBxB,gCAAAA,6BAA6BuB,MAAMX,kBAAnCZ,gCAA6CY;IACpE,MAAMc,QAAQP,QAAOV,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;IACtC,IAAIe,mBAAmBD,MAAM;QAC3B,OAAO;YAAER,SAAS;YAAQC,WAAWR,SAASS,EAAE;YAAES;YAAOR;YAAQG;YAASG;QAAe;IAC3F;IACA,MAAMhB,SAASmB,GAAG,CAACC,MAAM,CAAC;QACxB,CAAC3B,iBAAiBoB,SAAS,kBAAkB,EAAEG;QAC/CK,WAAWzB,WAAW0B,eAAe;IACvC;IACA,6DAA6D;IAC7D,MAAMzB,uBAAuBG,SAASmB,GAAG,EAAE;IAC3C,OAAO;QACLZ,SAAS;QACTC,WAAWR,SAASS,EAAE;QACtBS;QACAR;QACAG;QACAU,aAAa,EAAER,eAAAA,OAAQ;QACvBC;IACF;AACF"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * `POST /api/crm/contact-remove` — a holder lets contacts go, and a person's
19
+ * "no" to marketing stays (AGL-3338).
20
+ *
21
+ * Body: `{ hostId, contactIds }` under a site, or `{ orgId, hostId?,
22
+ * contactIds }` at the organization level. Answers `ContactRemoveResponse`:
23
+ * one outcome per contact, in the order the ids were sent.
24
+ *
25
+ * ## Delete is a detach, and a refusal is not let go
26
+ *
27
+ * A contact is shared by every site that captured the person. Removing it
28
+ * from one drops that holder's facet, consent grants, capture attribution
29
+ * and scope tokens, and the document is deleted only when nobody else holds
30
+ * it (`planContactDetach`). A refusal of marketing email is kept either way:
31
+ * a detach leaves it on the document for the holders that remain, and the
32
+ * last holder's delete copies every refusal into the organization's retained
33
+ * store in the same transaction (`removeContactKeepingRefusals`). The list
34
+ * gate reads both, so deleting a person is not a way to put somebody who
35
+ * said no on a list.
36
+ *
37
+ * ## Why a route
38
+ *
39
+ * The decision has to be made against the document's consent map, which a
40
+ * table row does not project, and it has to land in one commit with the
41
+ * retained store, which no client may write. So the rules close a client's
42
+ * update and delete of a contact, and the console's removals arrive here.
43
+ *
44
+ * ## Who may call it
45
+ *
46
+ * The CRM writer the other contact routes admit (`authorizeCrmWriter`),
47
+ * whose access reaches the site under a site. Each contact must be one the
48
+ * caller may read and, under a site, one the site's hub lists. Then, as the
49
+ * rules asked before the route existed:
50
+ *
51
+ * - a DELETE needs the caller's reach to cover every holder of the row, so
52
+ * a site collaborator cannot delete a person another site also holds;
53
+ * - a DETACH changes who a record is shared with, which only an org-wide
54
+ * member may do (`scopeUnchanged()` in the rules).
55
+ *
56
+ * ## The plan
57
+ *
58
+ * Letting a holder go is the CRM's "Remove from this site", included from
59
+ * Starter (AGL-2851), so a detach is refused on a plan without the CRM. A
60
+ * delete is not: removing a workspace's own record stays open on every plan.
61
+ */
62
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
63
+ /** What the suite gate names for a detach on a plan without the CRM. */
64
+ export declare const CONTACT_DETACH_SUITE_ACT = "Removing a contact from one site";
65
+ /**
66
+ * What a site is told for a contact it sees only because it was SHARED with
67
+ * it (AGL-3336): it holds nothing there to let go of, and taking the share
68
+ * away is a manager's unshare, not a removal.
69
+ */
70
+ export declare const CONTACT_REMOVE_SHARED_REFUSAL: string;
71
+ /** A contact read at the organization level that no site has captured. */
72
+ export declare const CONTACT_REMOVE_NO_HOLDER = "No site holds this contact yet, so there is no site to remove it from.";
73
+ /** What a site collaborator is told for a person another site also holds. */
74
+ export declare const CONTACT_REMOVE_SCOPED_REFUSAL: string;
75
+ export declare const crmContactRemoveHandler: PluginApiHandler;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * `POST /api/crm/contact-remove` — a holder lets contacts go, and a person's
18
+ * "no" to marketing stays (AGL-3338).
19
+ *
20
+ * Body: `{ hostId, contactIds }` under a site, or `{ orgId, hostId?,
21
+ * contactIds }` at the organization level. Answers `ContactRemoveResponse`:
22
+ * one outcome per contact, in the order the ids were sent.
23
+ *
24
+ * ## Delete is a detach, and a refusal is not let go
25
+ *
26
+ * A contact is shared by every site that captured the person. Removing it
27
+ * from one drops that holder's facet, consent grants, capture attribution
28
+ * and scope tokens, and the document is deleted only when nobody else holds
29
+ * it (`planContactDetach`). A refusal of marketing email is kept either way:
30
+ * a detach leaves it on the document for the holders that remain, and the
31
+ * last holder's delete copies every refusal into the organization's retained
32
+ * store in the same transaction (`removeContactKeepingRefusals`). The list
33
+ * gate reads both, so deleting a person is not a way to put somebody who
34
+ * said no on a list.
35
+ *
36
+ * ## Why a route
37
+ *
38
+ * The decision has to be made against the document's consent map, which a
39
+ * table row does not project, and it has to land in one commit with the
40
+ * retained store, which no client may write. So the rules close a client's
41
+ * update and delete of a contact, and the console's removals arrive here.
42
+ *
43
+ * ## Who may call it
44
+ *
45
+ * The CRM writer the other contact routes admit (`authorizeCrmWriter`),
46
+ * whose access reaches the site under a site. Each contact must be one the
47
+ * caller may read and, under a site, one the site's hub lists. Then, as the
48
+ * rules asked before the route existed:
49
+ *
50
+ * - a DELETE needs the caller's reach to cover every holder of the row, so
51
+ * a site collaborator cannot delete a person another site also holds;
52
+ * - a DETACH changes who a record is shared with, which only an org-wide
53
+ * member may do (`scopeUnchanged()` in the rules).
54
+ *
55
+ * ## The plan
56
+ *
57
+ * Letting a holder go is the CRM's "Remove from this site", included from
58
+ * Starter (AGL-2851), so a detach is refused on a plan without the CRM. A
59
+ * delete is not: removing a workspace's own record stays open on every plan.
60
+ */ import { consentGroupForHost, crmReadTokens, heldScopeTokens, isOrgWideMember, memberScopeTokens, planContactDetach, seenOnlyThroughGrant, visibleToTokens } from "@aglyn/aglyn/server";
61
+ import { firebaseAdmin, removeContactKeepingRefusals, restampCrmListFieldsOf } from "@aglyn/tenant-data-admin";
62
+ import { CRM_CONTACT_REMOVE_MAX } from "../model/contact-remove.js";
63
+ import { typed } from "./contact-profile.js";
64
+ import { readCrmRouteScope } from "./org-caller.js";
65
+ import { crmSuiteRefusal } from "./suite-gate.js";
66
+ import { authorizeCrmWriter, canReach } from "./task-routes.js";
67
+ import { contactPrimaryGroup } from "../model/contact-holder.js";
68
+ /** What the suite gate names for a detach on a plan without the CRM. */ export const CONTACT_DETACH_SUITE_ACT = 'Removing a contact from one site';
69
+ /**
70
+ * What a site is told for a contact it sees only because it was SHARED with
71
+ * it (AGL-3336): it holds nothing there to let go of, and taking the share
72
+ * away is a manager's unshare, not a removal.
73
+ */ export const CONTACT_REMOVE_SHARED_REFUSAL = 'This contact was shared with this site by another site. An owner or ' + 'admin can stop sharing it from its Sharing card.';
74
+ /** A contact read at the organization level that no site has captured. */ export const CONTACT_REMOVE_NO_HOLDER = 'No site holds this contact yet, so there is no site to remove it from.';
75
+ /** What a site collaborator is told for a person another site also holds. */ export const CONTACT_REMOVE_SCOPED_REFUSAL = 'Other sites hold this contact too, and your access is limited to ' + 'specific sites. Ask an organization administrator to remove it.';
76
+ /** How many contacts are removed at once — each in its own transaction. */ const CONCURRENCY = 20;
77
+ const refused = (contactId, error)=>({
78
+ contactId,
79
+ ok: false,
80
+ error
81
+ });
82
+ /** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */ function readContactIds(value) {
83
+ if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_REMOVE_MAX) {
84
+ return null;
85
+ }
86
+ const ids = value.map((id)=>typed(id, 200));
87
+ if (ids.some((id)=>!id || id.includes('/'))) return null;
88
+ return [
89
+ ...new Set(ids)
90
+ ];
91
+ }
92
+ /** Under a site, whether the caller's access reaches it. */ function reachesSite(writer, hostId) {
93
+ var _writer_member_hostAccess, _writer_member;
94
+ return writer.staff || isOrgWideMember(writer.member) || Boolean((_writer_member = writer.member) == null ? void 0 : (_writer_member_hostAccess = _writer_member.hostAccess) == null ? void 0 : _writer_member_hostAccess[hostId]);
95
+ }
96
+ /** Whether the caller's reach is the whole organization. */ const orgWide = (writer)=>writer.staff || writer.level === 'org' || isOrgWideMember(writer.member);
97
+ export const crmContactRemoveHandler = async (req, res)=>{
98
+ var _req_body;
99
+ if (req.method !== 'POST') {
100
+ res.setHeader('Allow', 'POST');
101
+ res.status(405).json({
102
+ error: 'Method not allowed'
103
+ });
104
+ return;
105
+ }
106
+ const body = (_req_body = req.body) != null ? _req_body : {};
107
+ const scope = readCrmRouteScope(body);
108
+ if (!scope) {
109
+ res.status(400).json({
110
+ error: 'Missing hostId'
111
+ });
112
+ return;
113
+ }
114
+ const contactIds = readContactIds(body['contactIds']);
115
+ if (!contactIds) {
116
+ res.status(400).json({
117
+ error: `Name between 1 and ${CRM_CONTACT_REMOVE_MAX} contacts.`
118
+ });
119
+ return;
120
+ }
121
+ try {
122
+ const writer = await authorizeCrmWriter(req, scope, {
123
+ suiteAct: null
124
+ });
125
+ if (writer.ok === false) {
126
+ res.status(writer.status).json(writer.body);
127
+ return;
128
+ }
129
+ if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {
130
+ res.status(403).json({
131
+ error: 'Your access to this organization does not reach this site.'
132
+ });
133
+ return;
134
+ }
135
+ const firestore = firebaseAdmin.app().firestore();
136
+ const contacts = firestore.collection('orgs').doc(writer.orgId).collection('contacts');
137
+ const siteGroup = scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null;
138
+ const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null;
139
+ const suite = crmSuiteRefusal(writer.org, CONTACT_DETACH_SUITE_ACT);
140
+ const callerTokens = new Set(memberScopeTokens(writer.member));
141
+ const nowMs = Date.now();
142
+ /*
143
+ * Decided inside each contact's transaction, against the document as it
144
+ * was read there: the reach, the holder letting go, and what that means.
145
+ */ const decide = (contact)=>{
146
+ const visibleTo = contact['visibleTo'];
147
+ if (!canReach(writer, visibleTo)) return {
148
+ refused: 'That contact is not visible to you.'
149
+ };
150
+ if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {
151
+ return {
152
+ refused: 'That contact is not visible to this site.'
153
+ };
154
+ }
155
+ if (siteGroup && siteGroup.hostIds.every((hostId)=>seenOnlyThroughGrant(contact, hostId))) {
156
+ return {
157
+ refused: CONTACT_REMOVE_SHARED_REFUSAL
158
+ };
159
+ }
160
+ const group = siteGroup != null ? siteGroup : contactPrimaryGroup(contact, writer.org);
161
+ if (!group.groupId || !group.hostIds.length) return {
162
+ refused: CONTACT_REMOVE_NO_HOLDER
163
+ };
164
+ const plan = planContactDetach(contact, group);
165
+ if (plan.action === 'delete') {
166
+ // The HOLDERS: a site the contact was only shared with holds
167
+ // nothing, so it never stands between a holder and its delete.
168
+ const holders = heldScopeTokens(contact);
169
+ if (!orgWide(writer) && !holders.every((token)=>callerTokens.has(token))) {
170
+ return {
171
+ refused: CONTACT_REMOVE_SCOPED_REFUSAL
172
+ };
173
+ }
174
+ return plan;
175
+ }
176
+ if (!orgWide(writer)) return {
177
+ refused: CONTACT_REMOVE_SCOPED_REFUSAL
178
+ };
179
+ if (suite) return {
180
+ refused: suite.body.error
181
+ };
182
+ return plan;
183
+ };
184
+ const outcomes = new Map();
185
+ const detached = [];
186
+ for(let at = 0; at < contactIds.length; at += CONCURRENCY){
187
+ await Promise.all(contactIds.slice(at, at + CONCURRENCY).map(async (contactId)=>{
188
+ const contactRef = contacts.doc(contactId);
189
+ try {
190
+ const removal = await removeContactKeepingRefusals({
191
+ contactRef,
192
+ decide,
193
+ nowMs
194
+ });
195
+ if (removal.outcome === 'missing') {
196
+ outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'));
197
+ } else if (removal.outcome === 'refused') {
198
+ outcomes.set(contactId, refused(contactId, removal.error));
199
+ } else if (removal.outcome === 'detached') {
200
+ detached.push(contactRef);
201
+ outcomes.set(contactId, {
202
+ contactId,
203
+ ok: true,
204
+ removed: 'detached'
205
+ });
206
+ } else {
207
+ outcomes.set(contactId, {
208
+ contactId,
209
+ ok: true,
210
+ removed: 'deleted'
211
+ });
212
+ }
213
+ } catch (error) {
214
+ console.error('[crm] contact-remove could not remove a contact', contactId, error);
215
+ outcomes.set(contactId, refused(contactId, 'The contact could not be removed.'));
216
+ }
217
+ }));
218
+ }
219
+ // A detached row still carries the facet keys and scoped search tokens of
220
+ // the holder that let it go, which the Contacts list filters by (AGL-3321).
221
+ // Its own catch: the contacts are removed by now, and a list stamp that
222
+ // could not move is not a removal that failed.
223
+ if (detached.length) {
224
+ await restampCrmListFieldsOf(detached, 'contacts').catch((error)=>{
225
+ console.error('[crm] contact-remove could not restamp list fields', error);
226
+ });
227
+ }
228
+ const answer = {
229
+ ok: true,
230
+ results: contactIds.map((contactId)=>{
231
+ var _outcomes_get;
232
+ return (_outcomes_get = outcomes.get(contactId)) != null ? _outcomes_get : refused(contactId, 'The contact could not be removed.');
233
+ })
234
+ };
235
+ res.status(200).json(answer);
236
+ } catch (error) {
237
+ console.error('[crm] contact-remove failed', error);
238
+ res.status(500).json({
239
+ error: 'The contacts could not be removed.'
240
+ });
241
+ }
242
+ };
243
+
244
+ //# sourceMappingURL=contact-remove.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-remove.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/contact-remove` — a holder lets contacts go, and a person's\n * \"no\" to marketing stays (AGL-3338).\n *\n * Body: `{ hostId, contactIds }` under a site, or `{ orgId, hostId?,\n * contactIds }` at the organization level. Answers `ContactRemoveResponse`:\n * one outcome per contact, in the order the ids were sent.\n *\n * ## Delete is a detach, and a refusal is not let go\n *\n * A contact is shared by every site that captured the person. Removing it\n * from one drops that holder's facet, consent grants, capture attribution\n * and scope tokens, and the document is deleted only when nobody else holds\n * it (`planContactDetach`). A refusal of marketing email is kept either way:\n * a detach leaves it on the document for the holders that remain, and the\n * last holder's delete copies every refusal into the organization's retained\n * store in the same transaction (`removeContactKeepingRefusals`). The list\n * gate reads both, so deleting a person is not a way to put somebody who\n * said no on a list.\n *\n * ## Why a route\n *\n * The decision has to be made against the document's consent map, which a\n * table row does not project, and it has to land in one commit with the\n * retained store, which no client may write. So the rules close a client's\n * update and delete of a contact, and the console's removals arrive here.\n *\n * ## Who may call it\n *\n * The CRM writer the other contact routes admit (`authorizeCrmWriter`),\n * whose access reaches the site under a site. Each contact must be one the\n * caller may read and, under a site, one the site's hub lists. Then, as the\n * rules asked before the route existed:\n *\n * - a DELETE needs the caller's reach to cover every holder of the row, so\n * a site collaborator cannot delete a person another site also holds;\n * - a DETACH changes who a record is shared with, which only an org-wide\n * member may do (`scopeUnchanged()` in the rules).\n *\n * ## The plan\n *\n * Letting a holder go is the CRM's \"Remove from this site\", included from\n * Starter (AGL-2851), so a detach is refused on a plan without the CRM. A\n * delete is not: removing a workspace's own record stays open on every plan.\n */\n\nimport {\n consentGroupForHost,\n crmReadTokens,\n heldScopeTokens,\n isOrgWideMember,\n memberScopeTokens,\n planContactDetach,\n type PluginApiHandler,\n seenOnlyThroughGrant,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n removeContactKeepingRefusals,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ContactRemoveOutcome,\n type ContactRemoveResponse,\n CRM_CONTACT_REMOVE_MAX,\n} from '../model/contact-remove'\nimport { typed } from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/** What the suite gate names for a detach on a plan without the CRM. */\nexport const CONTACT_DETACH_SUITE_ACT = 'Removing a contact from one site'\n\n/**\n * What a site is told for a contact it sees only because it was SHARED with\n * it (AGL-3336): it holds nothing there to let go of, and taking the share\n * away is a manager's unshare, not a removal.\n */\nexport const CONTACT_REMOVE_SHARED_REFUSAL =\n 'This contact was shared with this site by another site. An owner or ' +\n 'admin can stop sharing it from its Sharing card.'\n\n/** A contact read at the organization level that no site has captured. */\nexport const CONTACT_REMOVE_NO_HOLDER =\n 'No site holds this contact yet, so there is no site to remove it from.'\n\n/** What a site collaborator is told for a person another site also holds. */\nexport const CONTACT_REMOVE_SCOPED_REFUSAL =\n 'Other sites hold this contact too, and your access is limited to ' +\n 'specific sites. Ask an organization administrator to remove it.'\n\n/** How many contacts are removed at once — each in its own transaction. */\nconst CONCURRENCY = 20\n\nconst refused = (contactId: string, error: string): ContactRemoveOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_REMOVE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/** Under a site, whether the caller's access reaches it. */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\n/** Whether the caller's reach is the whole organization. */\nconst orgWide = (writer: Writer): boolean =>\n writer.staff || writer.level === 'org' || isOrgWideMember(writer.member)\n\nexport const crmContactRemoveHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_REMOVE_MAX} contacts.` })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const contacts = firestore.collection('orgs').doc(writer.orgId).collection('contacts')\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n const suite = crmSuiteRefusal(writer.org, CONTACT_DETACH_SUITE_ACT)\n const callerTokens = new Set<string>(memberScopeTokens(writer.member))\n const nowMs = Date.now()\n\n /*\n * Decided inside each contact's transaction, against the document as it\n * was read there: the reach, the holder letting go, and what that means.\n */\n const decide = (contact: Record<string, unknown>) => {\n const visibleTo = contact['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) return { refused: 'That contact is not visible to you.' }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n return { refused: 'That contact is not visible to this site.' }\n }\n if (siteGroup && siteGroup.hostIds.every((hostId) => seenOnlyThroughGrant(contact, hostId))) {\n return { refused: CONTACT_REMOVE_SHARED_REFUSAL }\n }\n const group = siteGroup ?? contactPrimaryGroup(contact, writer.org)\n if (!group.groupId || !group.hostIds.length) return { refused: CONTACT_REMOVE_NO_HOLDER }\n const plan = planContactDetach(contact, group)\n if (plan.action === 'delete') {\n // The HOLDERS: a site the contact was only shared with holds\n // nothing, so it never stands between a holder and its delete.\n const holders = heldScopeTokens(contact)\n if (!orgWide(writer) && !holders.every((token) => callerTokens.has(token))) {\n return { refused: CONTACT_REMOVE_SCOPED_REFUSAL }\n }\n return plan\n }\n if (!orgWide(writer)) return { refused: CONTACT_REMOVE_SCOPED_REFUSAL }\n if (suite) return { refused: suite.body.error }\n return plan\n }\n\n const outcomes = new Map<string, ContactRemoveOutcome>()\n const detached: FirebaseFirestore.DocumentReference[] = []\n for (let at = 0; at < contactIds.length; at += CONCURRENCY) {\n await Promise.all(\n contactIds.slice(at, at + CONCURRENCY).map(async (contactId) => {\n const contactRef = contacts.doc(contactId)\n try {\n const removal = await removeContactKeepingRefusals({ contactRef, decide, nowMs })\n if (removal.outcome === 'missing') {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n } else if (removal.outcome === 'refused') {\n outcomes.set(contactId, refused(contactId, removal.error))\n } else if (removal.outcome === 'detached') {\n detached.push(contactRef)\n outcomes.set(contactId, { contactId, ok: true, removed: 'detached' })\n } else {\n outcomes.set(contactId, { contactId, ok: true, removed: 'deleted' })\n }\n } catch (error) {\n console.error('[crm] contact-remove could not remove a contact', contactId, error)\n outcomes.set(contactId, refused(contactId, 'The contact could not be removed.'))\n }\n }),\n )\n }\n // A detached row still carries the facet keys and scoped search tokens of\n // the holder that let it go, which the Contacts list filters by (AGL-3321).\n // Its own catch: the contacts are removed by now, and a list stamp that\n // could not move is not a removal that failed.\n if (detached.length) {\n await restampCrmListFieldsOf(detached, 'contacts').catch((error: unknown) => {\n console.error('[crm] contact-remove could not restamp list fields', error)\n })\n }\n\n const answer: ContactRemoveResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) =>\n outcomes.get(contactId) ?? refused(contactId, 'The contact could not be removed.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-remove failed', error)\n res.status(500).json({ error: 'The contacts could not be removed.' })\n }\n}\n"],"names":["consentGroupForHost","crmReadTokens","heldScopeTokens","isOrgWideMember","memberScopeTokens","planContactDetach","seenOnlyThroughGrant","visibleToTokens","firebaseAdmin","removeContactKeepingRefusals","restampCrmListFieldsOf","CRM_CONTACT_REMOVE_MAX","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","CONTACT_DETACH_SUITE_ACT","CONTACT_REMOVE_SHARED_REFUSAL","CONTACT_REMOVE_NO_HOLDER","CONTACT_REMOVE_SCOPED_REFUSAL","CONCURRENCY","refused","contactId","error","ok","readContactIds","value","Array","isArray","length","ids","map","id","some","includes","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","orgWide","level","crmContactRemoveHandler","req","res","method","setHeader","status","json","body","scope","contactIds","suiteAct","firestore","app","contacts","collection","doc","orgId","siteGroup","org","siteTokens","suite","callerTokens","nowMs","Date","now","decide","contact","visibleTo","hostIds","every","group","groupId","plan","action","holders","token","has","outcomes","Map","detached","at","Promise","all","slice","contactRef","removal","outcome","set","push","removed","console","catch","answer","results","get"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SACEA,mBAAmB,EACnBC,aAAa,EACbC,eAAe,EACfC,eAAe,EACfC,iBAAiB,EACjBC,iBAAiB,EAEjBC,oBAAoB,EACpBC,eAAe,QACV,sBAAqB;AAC5B,SACEC,aAAa,EACbC,4BAA4B,EAC5BC,sBAAsB,QACjB,2BAA0B;AACjC,SAGEC,sBAAsB,QACjB,6BAAyB;AAChC,SAASC,KAAK,QAAQ,uBAAmB;AACzC,SAASC,iBAAiB,QAAQ,kBAAc;AAChD,SAASC,eAAe,QAAQ,kBAAc;AAC9C,SAASC,kBAAkB,EAAEC,QAAQ,QAAqB,mBAAe;AACzE,SAASC,mBAAmB,QAAQ,6BAAyB;AAE7D,sEAAsE,GACtE,OAAO,MAAMC,2BAA2B,mCAAkC;AAE1E;;;;CAIC,GACD,OAAO,MAAMC,gCACX,yEACA,mDAAkD;AAEpD,wEAAwE,GACxE,OAAO,MAAMC,2BACX,yEAAwE;AAE1E,2EAA2E,GAC3E,OAAO,MAAMC,gCACX,sEACA,kEAAiE;AAEnE,yEAAyE,GACzE,MAAMC,cAAc;AAEpB,MAAMC,UAAU,CAACC,WAAmBC,QAAyC,CAAA;QAC3ED;QACAE,IAAI;QACJD;IACF,CAAA;AAEA,iGAAiG,GACjG,SAASE,eAAeC,KAAc;IACpC,IAAI,CAACC,MAAMC,OAAO,CAACF,UAAU,CAACA,MAAMG,MAAM,IAAIH,MAAMG,MAAM,GAAGpB,wBAAwB;QACnF,OAAO;IACT;IACA,MAAMqB,MAAMJ,MAAMK,GAAG,CAAC,CAACC,KAAOtB,MAAMsB,IAAI;IACxC,IAAIF,IAAIG,IAAI,CAAC,CAACD,KAAO,CAACA,MAAMA,GAAGE,QAAQ,CAAC,OAAO,OAAO;IACtD,OAAO;WAAI,IAAIC,IAAIL;KAAK;AAC1B;AAEA,0DAA0D,GAC1D,SAASM,YAAYC,MAAc,EAAEC,MAAc;QAItCD,2BAAAA;IAHX,OACEA,OAAOE,KAAK,IACZtC,gBAAgBoC,OAAOG,MAAM,KAC7BC,SAASJ,iBAAAA,OAAOG,MAAM,sBAAbH,4BAAAA,eAAeK,UAAU,qBAA1B,AAACL,yBAAmE,CAACC,OAAO;AAExF;AAEA,0DAA0D,GAC1D,MAAMK,UAAU,CAACN,SACfA,OAAOE,KAAK,IAAIF,OAAOO,KAAK,KAAK,SAAS3C,gBAAgBoC,OAAOG,MAAM;AAEzE,OAAO,MAAMK,0BAA4C,OAAOC,KAAKC;QAMrDD;IALd,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO;QAAqB;QACnD;IACF;IACA,MAAM6B,QAAQN,YAAAA,IAAIM,IAAI,YAARN,YAAY,CAAC;IAC3B,MAAMO,QAAQ1C,kBAAkByC;IAChC,IAAI,CAACC,OAAO;QACVN,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO;QAAiB;QAC/C;IACF;IACA,MAAM+B,aAAa7B,eAAe2B,IAAI,CAAC,aAAa;IACpD,IAAI,CAACE,YAAY;QACfP,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO,CAAC,mBAAmB,EAAEd,uBAAuB,UAAU,CAAC;QAAC;QACvF;IACF;IAEA,IAAI;QACF,MAAM4B,SAAS,MAAMxB,mBAAmBiC,KAAKO,OAAO;YAAEE,UAAU;QAAK;QACrE,IAAIlB,OAAOb,EAAE,KAAK,OAAO;YACvBuB,IAAIG,MAAM,CAACb,OAAOa,MAAM,EAAEC,IAAI,CAACd,OAAOe,IAAI;YAC1C;QACF;QACA,IAAIC,MAAMT,KAAK,KAAK,UAAU,CAACR,YAAYC,QAAQgB,MAAMf,MAAM,GAAG;YAChES,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAE5B,OAAO;YAA6D;YAC3F;QACF;QAEA,MAAMiC,YAAYlD,cAAcmD,GAAG,GAAGD,SAAS;QAC/C,MAAME,WAAWF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACvB,OAAOwB,KAAK,EAAEF,UAAU,CAAC;QAC3E,MAAMG,YACJT,MAAMT,KAAK,KAAK,SAAS9C,oBAAoBuC,OAAO0B,GAAG,EAAEV,MAAMf,MAAM,IAAI;QAC3E,MAAM0B,aAAaF,YAAY/D,cAAc+D,aAAa;QAC1D,MAAMG,QAAQrD,gBAAgByB,OAAO0B,GAAG,EAAE/C;QAC1C,MAAMkD,eAAe,IAAI/B,IAAYjC,kBAAkBmC,OAAOG,MAAM;QACpE,MAAM2B,QAAQC,KAAKC,GAAG;QAEtB;;;KAGC,GACD,MAAMC,SAAS,CAACC;YACd,MAAMC,YAAYD,OAAO,CAAC,YAAY;YACtC,IAAI,CAACzD,SAASuB,QAAQmC,YAAY,OAAO;gBAAEnD,SAAS;YAAsC;YAC1F,IAAI2C,cAAc,CAAC3D,gBAAgBmE,WAAWR,aAAa;gBACzD,OAAO;oBAAE3C,SAAS;gBAA4C;YAChE;YACA,IAAIyC,aAAaA,UAAUW,OAAO,CAACC,KAAK,CAAC,CAACpC,SAAWlC,qBAAqBmE,SAASjC,UAAU;gBAC3F,OAAO;oBAAEjB,SAASJ;gBAA8B;YAClD;YACA,MAAM0D,QAAQb,oBAAAA,YAAa/C,oBAAoBwD,SAASlC,OAAO0B,GAAG;YAClE,IAAI,CAACY,MAAMC,OAAO,IAAI,CAACD,MAAMF,OAAO,CAAC5C,MAAM,EAAE,OAAO;gBAAER,SAASH;YAAyB;YACxF,MAAM2D,OAAO1E,kBAAkBoE,SAASI;YACxC,IAAIE,KAAKC,MAAM,KAAK,UAAU;gBAC5B,6DAA6D;gBAC7D,+DAA+D;gBAC/D,MAAMC,UAAU/E,gBAAgBuE;gBAChC,IAAI,CAAC5B,QAAQN,WAAW,CAAC0C,QAAQL,KAAK,CAAC,CAACM,QAAUd,aAAae,GAAG,CAACD,SAAS;oBAC1E,OAAO;wBAAE3D,SAASF;oBAA8B;gBAClD;gBACA,OAAO0D;YACT;YACA,IAAI,CAAClC,QAAQN,SAAS,OAAO;gBAAEhB,SAASF;YAA8B;YACtE,IAAI8C,OAAO,OAAO;gBAAE5C,SAAS4C,MAAMb,IAAI,CAAC7B,KAAK;YAAC;YAC9C,OAAOsD;QACT;QAEA,MAAMK,WAAW,IAAIC;QACrB,MAAMC,WAAkD,EAAE;QAC1D,IAAK,IAAIC,KAAK,GAAGA,KAAK/B,WAAWzB,MAAM,EAAEwD,MAAMjE,YAAa;YAC1D,MAAMkE,QAAQC,GAAG,CACfjC,WAAWkC,KAAK,CAACH,IAAIA,KAAKjE,aAAaW,GAAG,CAAC,OAAOT;gBAChD,MAAMmE,aAAa/B,SAASE,GAAG,CAACtC;gBAChC,IAAI;oBACF,MAAMoE,UAAU,MAAMnF,6BAA6B;wBAAEkF;wBAAYnB;wBAAQH;oBAAM;oBAC/E,IAAIuB,QAAQC,OAAO,KAAK,WAAW;wBACjCT,SAASU,GAAG,CAACtE,WAAWD,QAAQC,WAAW;oBAC7C,OAAO,IAAIoE,QAAQC,OAAO,KAAK,WAAW;wBACxCT,SAASU,GAAG,CAACtE,WAAWD,QAAQC,WAAWoE,QAAQnE,KAAK;oBAC1D,OAAO,IAAImE,QAAQC,OAAO,KAAK,YAAY;wBACzCP,SAASS,IAAI,CAACJ;wBACdP,SAASU,GAAG,CAACtE,WAAW;4BAAEA;4BAAWE,IAAI;4BAAMsE,SAAS;wBAAW;oBACrE,OAAO;wBACLZ,SAASU,GAAG,CAACtE,WAAW;4BAAEA;4BAAWE,IAAI;4BAAMsE,SAAS;wBAAU;oBACpE;gBACF,EAAE,OAAOvE,OAAO;oBACdwE,QAAQxE,KAAK,CAAC,mDAAmDD,WAAWC;oBAC5E2D,SAASU,GAAG,CAACtE,WAAWD,QAAQC,WAAW;gBAC7C;YACF;QAEJ;QACA,0EAA0E;QAC1E,4EAA4E;QAC5E,wEAAwE;QACxE,+CAA+C;QAC/C,IAAI8D,SAASvD,MAAM,EAAE;YACnB,MAAMrB,uBAAuB4E,UAAU,YAAYY,KAAK,CAAC,CAACzE;gBACxDwE,QAAQxE,KAAK,CAAC,sDAAsDA;YACtE;QACF;QAEA,MAAM0E,SAAgC;YACpCzE,IAAI;YACJ0E,SAAS5C,WAAWvB,GAAG,CACrB,CAACT;oBACC4D;wBAAAA,gBAAAA,SAASiB,GAAG,CAAC7E,sBAAb4D,gBAA2B7D,QAAQC,WAAW;;QAEpD;QACAyB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC8C;IACvB,EAAE,OAAO1E,OAAO;QACdwE,QAAQxE,KAAK,CAAC,+BAA+BA;QAC7CwB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE5B,OAAO;QAAqC;IACrE;AACF,EAAC"}
@@ -68,7 +68,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
68
68
  * link: summed per company and settled once the contacts are written, so a
69
69
  * company deleted in between cannot fail the save of the people at it — the
70
70
  * company page's live aggregate is what corrects a count that did not move.
71
- */ import { consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, campaignMembershipValue, contactCampaignFieldPath, contactFacetPath, contactPrimaryGroup, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, normalizeAddress, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
71
+ */ import { consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, campaignMembershipValue, contactCampaignFieldPath, contactFacetPath, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, normalizeAddress, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
72
72
  import { companyContactsCountFields, contactCompanyLinkFields, firebaseAdmin, resolveOrgMembership, restampCrmListFieldsOf } from "@aglyn/tenant-data-admin";
73
73
  import { FieldValue } from "firebase-admin/firestore";
74
74
  import { CONTACT_UPDATE_FIELDS, CRM_CONTACT_UPDATE_MAX } from "../model/contact-update.js";
@@ -76,6 +76,7 @@ import { CONTACT_NOTES_MAX, CONTACT_PHONE_REFUSAL, CONTACT_TAGS_MAX, normalizeTa
76
76
  import { readCrmRouteScope } from "./org-caller.js";
77
77
  import { crmSuiteRefusal } from "./suite-gate.js";
78
78
  import { authorizeCrmWriter, canReach } from "./task-routes.js";
79
+ import { contactPrimaryGroup } from "../model/contact-holder.js";
79
80
  /** What the suite gate names for each field a request can carry. */ const SUITE_ACTS = {
80
81
  name: "Editing a contact's profile",
81
82
  phone: "Editing a contact's profile",