@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,274 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * MERGING TWO CONTACTS — the write (AGL-2625).
19
+ *
20
+ * `planContactMerge` says what the survivor becomes; this performs it, and
21
+ * moves everything that pointed at the merged record. One function behind
22
+ * two doors — the console's `crm/contacts-merge` route and
23
+ * `POST /v1/contacts/{id}/merge` — so the REST caller and the record page
24
+ * cannot merge two different ways.
25
+ *
26
+ * ## The order is the crash-safety argument
27
+ *
28
+ * 1. Every deal, task and activity naming the merged contact is repointed
29
+ * at the survivor, and every lead converted into it. Batched, bounded,
30
+ * idempotent: a re-run finds nothing left to move.
31
+ * 2. Then, in ONE transaction: the survivor takes the plan, every address
32
+ * it now answers to is indexed at it, and the merged document is
33
+ * deleted — with both records re-read inside the transaction, so two
34
+ * merges racing on one pair cannot both succeed and a plan is never
35
+ * computed on a document that changed under it.
36
+ *
37
+ * Children first because a process that dies between the two steps leaves
38
+ * children pointing at a record that EXISTS — the survivor — while the merged
39
+ * document is still there for a retry to fold in. The other order would
40
+ * leave deals pointing at a deleted contact, which is a record nobody can
41
+ * open from anywhere.
42
+ *
43
+ * ## What it does not move
44
+ *
45
+ * List memberships, suppression entries and the delivery log are keyed by
46
+ * ADDRESS, not by contact id, and stay under the merged address; a capture
47
+ * on that address resolves to the survivor through the index, which is the
48
+ * fact those keys need. A company's contacts count drops only for a company
49
+ * both records named — the survivor still names the rest.
50
+ */ import { CONTACT_EMAIL_INDEX_COLLECTION, contactEmails } from "@aglyn/aglyn/app-utils/contacts";
51
+ import { planContactMerge } from "../model/contact-merge.js";
52
+ import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
53
+ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
54
+ import { FieldValue } from "firebase-admin/firestore";
55
+ import { settleCompanyContactsCounts } from "@aglyn/tenant-data-admin/server/contact-company-link";
56
+ import { recomputeCrmNextTaskAt } from "@aglyn/tenant-data-admin/server/crm-next-activity";
57
+ import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
58
+ import { logHostActivity } from "@aglyn/tenant-data-admin/server/organizations";
59
+ /** How many pointing rows one repoint pass reads — and one batch writes. */ const REPOINT_PAGE = 400;
60
+ /** A ceiling on passes, so a query that keeps answering cannot loop forever. */ const REPOINT_PASSES = 25;
61
+ /**
62
+ * Point every row of `collection` whose `contactId` is `from` at `to`.
63
+ *
64
+ * Page, batch, repeat: each pass reads the rows still pointing at the
65
+ * merged record — the ones a previous pass moved no longer match — so the
66
+ * loop ends when a pass finds nothing. `updatedAt` moves with the pointer
67
+ * because an integration syncing by `updatedAfter` has to see the change.
68
+ */ async function repointContactId(firestore, collection, from, to) {
69
+ let moved = 0;
70
+ for(let pass = 0; pass < REPOINT_PASSES; pass += 1){
71
+ const page = await collection.where('contactId', '==', from).limit(REPOINT_PAGE).get();
72
+ if (page.empty) break;
73
+ const batch = firestore.batch();
74
+ for (const row of page.docs){
75
+ batch.update(row.ref, {
76
+ contactId: to,
77
+ updatedAt: FieldValue.serverTimestamp()
78
+ });
79
+ }
80
+ await batch.commit();
81
+ moved += page.size;
82
+ if (page.size < REPOINT_PAGE) break;
83
+ }
84
+ return moved;
85
+ }
86
+ /**
87
+ * The leads converted into the merged contact, restamped at the survivor.
88
+ *
89
+ * A lead lives at `orgs/{orgId}/leads/{personKey(email)}` (AGL-3275), so there
90
+ * is still no query — one document per address, asked for directly. This used
91
+ * to be bounded by sites TIMES addresses because a lead lived under each site;
92
+ * it is bounded by addresses now, and the sites drop out of the signature
93
+ * because the collection no longer has one per site to walk.
94
+ */ async function repointLeads(orgRef, emails, from, to) {
95
+ let moved = 0;
96
+ /*
97
+ * ONE ROW PER ADDRESS, on the org (AGL-3275). This walked the sites because
98
+ * there was a lead under each of them; re-pointing the same document once
99
+ * per site would now be the same write repeated, and the second would find
100
+ * `convertedContactId` already moved and skip — a silent undercount rather
101
+ * than a wrong answer, but an undercount the caller reports to the operator.
102
+ */ for (const email of emails){
103
+ const key = personKey(email);
104
+ if (!key) continue;
105
+ const leadRef = orgRef.collection('leads').doc(key);
106
+ const lead = await leadRef.get();
107
+ if (!lead.exists || lead.get('convertedContactId') !== from) continue;
108
+ await leadRef.update({
109
+ convertedContactId: to,
110
+ updatedAt: FieldValue.serverTimestamp()
111
+ });
112
+ moved += 1;
113
+ }
114
+ return moved;
115
+ }
116
+ export async function mergeContacts(options) {
117
+ var _mergedBefore_data, _survivorBefore_data, _contactEmails_, _plan_emails_;
118
+ const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options;
119
+ if (!survivorId || !mergedId || survivorId === mergedId) {
120
+ return {
121
+ ok: false,
122
+ reason: 'same-record'
123
+ };
124
+ }
125
+ const contactsRef = orgRef.collection('contacts');
126
+ const survivorRef = contactsRef.doc(survivorId);
127
+ const mergedRef = contactsRef.doc(mergedId);
128
+ const [survivorBefore, mergedBefore] = await Promise.all([
129
+ survivorRef.get(),
130
+ mergedRef.get()
131
+ ]);
132
+ if (!survivorBefore.exists) return {
133
+ ok: false,
134
+ reason: 'survivor-missing'
135
+ };
136
+ if (!mergedBefore.exists) return {
137
+ ok: false,
138
+ reason: 'merged-missing'
139
+ };
140
+ const mergedData = (_mergedBefore_data = mergedBefore.data()) != null ? _mergedBefore_data : {};
141
+ const survivorData = (_survivorBefore_data = survivorBefore.data()) != null ? _survivorBefore_data : {};
142
+ /*==========================================
143
+ * 1. THE CHILDREN — before the parent, for the reason the header gives.
144
+ *=========================================*/ const [deals, tasks, activities] = await Promise.all([
145
+ repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),
146
+ repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),
147
+ repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.activities), mergedId, survivorId)
148
+ ]);
149
+ const leads = await repointLeads(orgRef, contactEmails(mergedData), mergedId, survivorId);
150
+ /*==========================================
151
+ * 2. THE SWAP — one transaction over both documents and the index.
152
+ *=========================================*/ const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION);
153
+ const swapped = await firestore.runTransaction(async (transaction)=>{
154
+ var _survivor_data, _merged_data, _survivor_data1;
155
+ const [survivor, merged] = await Promise.all([
156
+ transaction.get(survivorRef),
157
+ transaction.get(mergedRef)
158
+ ]);
159
+ if (!survivor.exists) return {
160
+ reason: 'survivor-missing'
161
+ };
162
+ if (!merged.exists) return {
163
+ reason: 'merged-missing'
164
+ };
165
+ const plan = planContactMerge((_survivor_data = survivor.data()) != null ? _survivor_data : {}, (_merged_data = merged.data()) != null ? _merged_data : {});
166
+ transaction.set(survivorRef, _extends({}, plan.survivor, {
167
+ updatedAt: FieldValue.serverTimestamp()
168
+ }), {
169
+ merge: true
170
+ });
171
+ for (const email of plan.emails){
172
+ const key = personKey(email);
173
+ if (!key) continue;
174
+ transaction.set(index.doc(key), {
175
+ email,
176
+ contactId: survivorId,
177
+ updatedAt: FieldValue.serverTimestamp()
178
+ }, {
179
+ merge: true
180
+ });
181
+ }
182
+ transaction.delete(mergedRef);
183
+ return {
184
+ plan,
185
+ survivor: (_survivor_data1 = survivor.data()) != null ? _survivor_data1 : {}
186
+ };
187
+ });
188
+ if ('reason' in swapped) return {
189
+ ok: false,
190
+ reason: swapped.reason
191
+ };
192
+ const { plan } = swapped;
193
+ // The survivor now carries both records' addresses, holders and facets —
194
+ // what the Contacts list searches and filters by (AGL-3321).
195
+ await restampCrmListFieldsAt(survivorRef, 'contacts');
196
+ /*
197
+ * The companies both records named counted two people and now count one
198
+ * (AGL-2613). Settled after the swap the way the capture settles its
199
+ * counts: a figure that could not move is logged, and the company page's
200
+ * live aggregate corrects it.
201
+ */ await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {
202
+ companyId: null,
203
+ mirror: null,
204
+ counts: plan.companyCounts
205
+ });
206
+ /*
207
+ * The survivor now owns the merged record's open tasks too, so its next
208
+ * activity is the earliest of both (AGL-2661). Settled the same way: a
209
+ * figure that could not move is logged, and the Fields section's
210
+ * recompute corrects it.
211
+ */ await recomputeCrmNextTaskAt(firestore, orgRef.id, [
212
+ {
213
+ contactId: survivorId
214
+ }
215
+ ]).catch((error)=>{
216
+ console.error('contact merge next activity could not be recomputed', survivorId, error);
217
+ });
218
+ const mergedEmail = (_contactEmails_ = contactEmails(mergedData)[0]) != null ? _contactEmails_ : '';
219
+ const survivorEmail = (_plan_emails_ = plan.emails[0]) != null ? _plan_emails_ : '';
220
+ // The canonical name — the one identity every holder shares — for the
221
+ // feed; the address stands in for a person nobody has named.
222
+ const survivorName = typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : '';
223
+ /*
224
+ * THE TIMELINE NOTE, on the survivor: the merge is an event in this
225
+ * person's history, and the record page is where a reader looks for one.
226
+ * Filed under the calling site, or the survivor's first site for a door
227
+ * with none; its own catch, because a note that could not be written must
228
+ * not read as a merge that did not happen.
229
+ */ try {
230
+ var _survivorData_hostId;
231
+ await orgRef.collection(CRM_COLLECTIONS.activities).add(_extends({
232
+ kind: 'note',
233
+ body: `Merged with ${mergedEmail}`,
234
+ atMs: Date.now(),
235
+ byUid: actor.uid
236
+ }, options.actorName || actor.email ? {
237
+ byName: options.actorName || actor.email
238
+ } : {}, {
239
+ contactId: survivorId,
240
+ visibleTo: plan.visibleTo,
241
+ hostId: hostId != null ? hostId : String((_survivorData_hostId = survivorData['hostId']) != null ? _survivorData_hostId : ''),
242
+ createdByUid: actor.uid,
243
+ createdAt: FieldValue.serverTimestamp(),
244
+ updatedAt: FieldValue.serverTimestamp()
245
+ }));
246
+ } catch (error) {
247
+ console.error('[contact-merge] timeline note failed', survivorId, error);
248
+ }
249
+ // The audit line (AGL-2622), in the feed of the site whose console did
250
+ // the work. A door with no site — the API — has no host feed to write.
251
+ if (hostId) {
252
+ await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {
253
+ type: 'contact',
254
+ id: survivorId,
255
+ name: survivorName || survivorEmail
256
+ });
257
+ }
258
+ return {
259
+ ok: true,
260
+ survivorId,
261
+ survivorEmail,
262
+ mergedId,
263
+ mergedEmail,
264
+ emails: plan.emails,
265
+ repointed: {
266
+ deals,
267
+ tasks,
268
+ activities,
269
+ leads
270
+ }
271
+ };
272
+ }
273
+
274
+ //# sourceMappingURL=merge-contacts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/merge-contacts.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 * MERGING TWO CONTACTS — the write (AGL-2625).\n *\n * `planContactMerge` says what the survivor becomes; this performs it, and\n * moves everything that pointed at the merged record. One function behind\n * two doors — the console's `crm/contacts-merge` route and\n * `POST /v1/contacts/{id}/merge` — so the REST caller and the record page\n * cannot merge two different ways.\n *\n * ## The order is the crash-safety argument\n *\n * 1. Every deal, task and activity naming the merged contact is repointed\n * at the survivor, and every lead converted into it. Batched, bounded,\n * idempotent: a re-run finds nothing left to move.\n * 2. Then, in ONE transaction: the survivor takes the plan, every address\n * it now answers to is indexed at it, and the merged document is\n * deleted — with both records re-read inside the transaction, so two\n * merges racing on one pair cannot both succeed and a plan is never\n * computed on a document that changed under it.\n *\n * Children first because a process that dies between the two steps leaves\n * children pointing at a record that EXISTS — the survivor — while the merged\n * document is still there for a retry to fold in. The other order would\n * leave deals pointing at a deleted contact, which is a record nobody can\n * open from anywhere.\n *\n * ## What it does not move\n *\n * List memberships, suppression entries and the delivery log are keyed by\n * ADDRESS, not by contact id, and stay under the merged address; a capture\n * on that address resolves to the survivor through the index, which is the\n * fact those keys need. A company's contacts count drops only for a company\n * both records named — the survivor still names the rest.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n contactEmails,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter'\nimport { planContactMerge } from '../model/contact-merge'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { settleCompanyContactsCounts } from '@aglyn/tenant-data-admin/server/contact-company-link'\nimport { recomputeCrmNextTaskAt } from '@aglyn/tenant-data-admin/server/crm-next-activity'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { logHostActivity } from '@aglyn/tenant-data-admin/server/organizations'\n\n/** How many pointing rows one repoint pass reads — and one batch writes. */\nconst REPOINT_PAGE = 400\n/** A ceiling on passes, so a query that keeps answering cannot loop forever. */\nconst REPOINT_PASSES = 25\n\nexport interface MergeContactsOptions {\n firestore: FirebaseFirestore.Firestore\n /** `orgs/{orgId}` — the org whose contacts both records are. */\n orgRef: FirebaseFirestore.DocumentReference\n /** The record that stays, and keeps its address as the identity. */\n survivorId: string\n /** The record folded in and deleted. */\n mergedId: string\n actor: HostActivityActor\n /**\n * The site whose console did the work: where the activity feed entry\n * lands, and the site a timeline note is filed under. `null` for a door\n * with no site — the REST API — which logs no host entry.\n */\n hostId: string | null\n /** The actor's display name for the timeline note, when the door knows it. */\n actorName?: string | null\n}\n\nexport interface MergeContactsRepointed {\n deals: number\n tasks: number\n activities: number\n leads: number\n}\n\nexport type MergeContactsResult =\n | {\n ok: true\n survivorId: string\n survivorEmail: string\n mergedId: string\n mergedEmail: string\n /** Every address the survivor answers to now, primary first. */\n emails: string[]\n repointed: MergeContactsRepointed\n }\n | { ok: false; reason: 'same-record' | 'survivor-missing' | 'merged-missing' }\n\n/**\n * Point every row of `collection` whose `contactId` is `from` at `to`.\n *\n * Page, batch, repeat: each pass reads the rows still pointing at the\n * merged record — the ones a previous pass moved no longer match — so the\n * loop ends when a pass finds nothing. `updatedAt` moves with the pointer\n * because an integration syncing by `updatedAfter` has to see the change.\n */\nasync function repointContactId(\n firestore: FirebaseFirestore.Firestore,\n collection: FirebaseFirestore.CollectionReference,\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n for (let pass = 0; pass < REPOINT_PASSES; pass += 1) {\n const page = await collection\n .where('contactId', '==', from)\n .limit(REPOINT_PAGE)\n .get()\n if (page.empty) break\n const batch = firestore.batch()\n for (const row of page.docs) {\n batch.update(row.ref, {\n contactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n moved += page.size\n if (page.size < REPOINT_PAGE) break\n }\n return moved\n}\n\n/**\n * The leads converted into the merged contact, restamped at the survivor.\n *\n * A lead lives at `orgs/{orgId}/leads/{personKey(email)}` (AGL-3275), so there\n * is still no query — one document per address, asked for directly. This used\n * to be bounded by sites TIMES addresses because a lead lived under each site;\n * it is bounded by addresses now, and the sites drop out of the signature\n * because the collection no longer has one per site to walk.\n */\nasync function repointLeads(\n orgRef: FirebaseFirestore.DocumentReference,\n emails: readonly string[],\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n /*\n * ONE ROW PER ADDRESS, on the org (AGL-3275). This walked the sites because\n * there was a lead under each of them; re-pointing the same document once\n * per site would now be the same write repeated, and the second would find\n * `convertedContactId` already moved and skip — a silent undercount rather\n * than a wrong answer, but an undercount the caller reports to the operator.\n */\n for (const email of emails) {\n const key = personKey(email)\n if (!key) continue\n const leadRef = orgRef.collection('leads').doc(key)\n const lead = await leadRef.get()\n if (!lead.exists || lead.get('convertedContactId') !== from) continue\n await leadRef.update({\n convertedContactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n moved += 1\n }\n return moved\n}\n\nexport async function mergeContacts(\n options: MergeContactsOptions,\n): Promise<MergeContactsResult> {\n const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options\n if (!survivorId || !mergedId || survivorId === mergedId) {\n return { ok: false, reason: 'same-record' }\n }\n const contactsRef = orgRef.collection('contacts')\n const survivorRef = contactsRef.doc(survivorId)\n const mergedRef = contactsRef.doc(mergedId)\n const [survivorBefore, mergedBefore] = await Promise.all([\n survivorRef.get(),\n mergedRef.get(),\n ])\n if (!survivorBefore.exists) return { ok: false, reason: 'survivor-missing' }\n if (!mergedBefore.exists) return { ok: false, reason: 'merged-missing' }\n const mergedData = (mergedBefore.data() ?? {}) as Record<string, unknown>\n const survivorData = (survivorBefore.data() ?? {}) as Record<string, unknown>\n\n /*==========================================\n * 1. THE CHILDREN — before the parent, for the reason the header gives.\n *=========================================*/\n const [deals, tasks, activities] = await Promise.all([\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),\n repointContactId(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.activities),\n mergedId,\n survivorId,\n ),\n ])\n const leads = await repointLeads(\n orgRef,\n contactEmails(mergedData),\n mergedId,\n survivorId,\n )\n\n /*==========================================\n * 2. THE SWAP — one transaction over both documents and the index.\n *=========================================*/\n const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION)\n const swapped = await firestore.runTransaction(async (transaction) => {\n const [survivor, merged] = await Promise.all([\n transaction.get(survivorRef),\n transaction.get(mergedRef),\n ])\n if (!survivor.exists) return { reason: 'survivor-missing' as const }\n if (!merged.exists) return { reason: 'merged-missing' as const }\n const plan = planContactMerge(\n (survivor.data() ?? {}) as Record<string, unknown>,\n (merged.data() ?? {}) as Record<string, unknown>,\n )\n transaction.set(\n survivorRef,\n { ...plan.survivor, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n for (const email of plan.emails) {\n const key = personKey(email)\n if (!key) continue\n transaction.set(\n index.doc(key),\n { email, contactId: survivorId, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n transaction.delete(mergedRef)\n return { plan, survivor: (survivor.data() ?? {}) as Record<string, unknown> }\n })\n if ('reason' in swapped) return { ok: false, reason: swapped.reason }\n const { plan } = swapped\n // The survivor now carries both records' addresses, holders and facets —\n // what the Contacts list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(survivorRef, 'contacts')\n\n /*\n * The companies both records named counted two people and now count one\n * (AGL-2613). Settled after the swap the way the capture settles its\n * counts: a figure that could not move is logged, and the company page's\n * live aggregate corrects it.\n */\n await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {\n companyId: null,\n mirror: null,\n counts: plan.companyCounts,\n })\n /*\n * The survivor now owns the merged record's open tasks too, so its next\n * activity is the earliest of both (AGL-2661). Settled the same way: a\n * figure that could not move is logged, and the Fields section's\n * recompute corrects it.\n */\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [{ contactId: survivorId }]).catch(\n (error: unknown) => {\n console.error('contact merge next activity could not be recomputed', survivorId, error)\n },\n )\n\n const mergedEmail = contactEmails(mergedData)[0] ?? ''\n const survivorEmail = plan.emails[0] ?? ''\n // The canonical name — the one identity every holder shares — for the\n // feed; the address stands in for a person nobody has named.\n const survivorName =\n typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : ''\n\n /*\n * THE TIMELINE NOTE, on the survivor: the merge is an event in this\n * person's history, and the record page is where a reader looks for one.\n * Filed under the calling site, or the survivor's first site for a door\n * with none; its own catch, because a note that could not be written must\n * not read as a merge that did not happen.\n */\n try {\n await orgRef.collection(CRM_COLLECTIONS.activities).add({\n kind: 'note',\n body: `Merged with ${mergedEmail}`,\n atMs: Date.now(),\n byUid: actor.uid,\n ...(options.actorName || actor.email\n ? { byName: options.actorName || actor.email }\n : {}),\n contactId: survivorId,\n visibleTo: plan.visibleTo,\n hostId: hostId ?? String(survivorData['hostId'] ?? ''),\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('[contact-merge] timeline note failed', survivorId, error)\n }\n\n // The audit line (AGL-2622), in the feed of the site whose console did\n // the work. A door with no site — the API — has no host feed to write.\n if (hostId) {\n await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {\n type: 'contact',\n id: survivorId,\n name: survivorName || survivorEmail,\n })\n }\n\n return {\n ok: true,\n survivorId,\n survivorEmail,\n mergedId,\n mergedEmail,\n emails: plan.emails,\n repointed: { deals, tasks, activities, leads },\n }\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","planContactMerge","CRM_COLLECTIONS","personKey","FieldValue","settleCompanyContactsCounts","recomputeCrmNextTaskAt","restampCrmListFieldsAt","logHostActivity","REPOINT_PAGE","REPOINT_PASSES","repointContactId","firestore","collection","from","to","moved","pass","page","where","limit","get","empty","batch","row","docs","update","ref","contactId","updatedAt","serverTimestamp","commit","size","repointLeads","orgRef","emails","email","key","leadRef","doc","lead","exists","convertedContactId","mergeContacts","options","mergedBefore","survivorBefore","plan","survivorId","mergedId","actor","hostId","ok","reason","contactsRef","survivorRef","mergedRef","Promise","all","mergedData","data","survivorData","deals","tasks","activities","leads","index","swapped","runTransaction","transaction","survivor","merged","set","merge","delete","companies","companyId","mirror","counts","companyCounts","id","catch","error","console","mergedEmail","survivorEmail","survivorName","add","kind","body","atMs","Date","now","byUid","uid","actorName","byName","visibleTo","String","createdByUid","createdAt","type","name","repointed"],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index';
18
+ /**
19
+ * The CRM's `pipeline` index (AGL-3080): the organization's deal pipelines
20
+ * and their stages, as another plugin reads them — an automation drafted by
21
+ * the AI names a stage a deal moves to — without knowing where pipelines are
22
+ * stored or what an archived one looks like.
23
+ *
24
+ * Org-scoped (`orgId` required; a site-only scope answers nothing). With a
25
+ * `hostId`, only the pipelines that site can see — the ones shared with it
26
+ * or with every site — which is the set that site's CRM offers. An archived
27
+ * pipeline is left out of every answer.
28
+ *
29
+ * `name` is the pipeline's, or `Untitled pipeline` for one saved without,
30
+ * which is what the CRM itself calls it. A pipeline's `facts`:
31
+ * `stages: Array<{ id: string, name: string }>`, in the pipeline's order,
32
+ * each stage that has an id (a stage saved without a name has `name: ''`).
33
+ */
34
+ /** What the CRM calls a pipeline saved without a name. */
35
+ export declare const UNTITLED_PIPELINE = "Untitled pipeline";
36
+ export declare const pipelineRecordIndex: PluginRecordIndex;
@@ -0,0 +1,87 @@
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
+ */ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
+ import { scopedToHost } from "@aglyn/tenant-data-admin/server/organizations";
19
+ /**
20
+ * The CRM's `pipeline` index (AGL-3080): the organization's deal pipelines
21
+ * and their stages, as another plugin reads them — an automation drafted by
22
+ * the AI names a stage a deal moves to — without knowing where pipelines are
23
+ * stored or what an archived one looks like.
24
+ *
25
+ * Org-scoped (`orgId` required; a site-only scope answers nothing). With a
26
+ * `hostId`, only the pipelines that site can see — the ones shared with it
27
+ * or with every site — which is the set that site's CRM offers. An archived
28
+ * pipeline is left out of every answer.
29
+ *
30
+ * `name` is the pipeline's, or `Untitled pipeline` for one saved without,
31
+ * which is what the CRM itself calls it. A pipeline's `facts`:
32
+ * `stages: Array<{ id: string, name: string }>`, in the pipeline's order,
33
+ * each stage that has an id (a stage saved without a name has `name: ''`).
34
+ */ /** What the CRM calls a pipeline saved without a name. */ export const UNTITLED_PIPELINE = 'Untitled pipeline';
35
+ const PIPELINE_FIELDS = [
36
+ 'name',
37
+ 'stages',
38
+ 'archivedAt'
39
+ ];
40
+ const str = (value)=>typeof value === 'string' ? value.trim() : '';
41
+ function pipelineRecord(id, data) {
42
+ if (!data || Number(data['archivedAt']) > 0) return null;
43
+ const stages = (Array.isArray(data['stages']) ? data['stages'] : []).map((stage)=>{
44
+ const entry = stage != null ? stage : {};
45
+ return {
46
+ id: str(entry['id']),
47
+ name: str(entry['name'])
48
+ };
49
+ }).filter((stage)=>stage.id);
50
+ return {
51
+ id,
52
+ name: str(data['name']) || UNTITLED_PIPELINE,
53
+ facts: {
54
+ stages
55
+ }
56
+ };
57
+ }
58
+ function pipelineCollection(orgId) {
59
+ return firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('pipelines');
60
+ }
61
+ export const pipelineRecordIndex = {
62
+ async list ({ orgId, hostId, limit }) {
63
+ if (!orgId || limit <= 0) return {
64
+ records: [],
65
+ truncated: false
66
+ };
67
+ const collection = pipelineCollection(orgId);
68
+ // One more than asked, so `truncated` is a fact rather than a guess; the
69
+ // archived rows are filtered after the read.
70
+ const snapshot = await (hostId ? scopedToHost(collection, hostId) : collection).select(...PIPELINE_FIELDS).limit(limit + 1).get();
71
+ const records = snapshot.docs.slice(0, limit).map((doc)=>pipelineRecord(doc.id, doc.data())).filter((record)=>record !== null);
72
+ return {
73
+ records,
74
+ truncated: snapshot.docs.length > limit
75
+ };
76
+ },
77
+ async get ({ orgId, hostId, id }) {
78
+ if (!orgId || !id) return null;
79
+ const snapshot = await pipelineCollection(orgId).doc(id).get();
80
+ const data = snapshot.exists ? snapshot.data() : undefined;
81
+ // A site asks about the pipelines it can see, and no other.
82
+ if (hostId && !visibleToHost(data == null ? void 0 : data['visibleTo'], hostId)) return null;
83
+ return pipelineRecord(snapshot.id, data);
84
+ }
85
+ };
86
+
87
+ //# sourceMappingURL=pipeline-record-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/pipeline-record-index.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 type {\n PluginIndexedRecord,\n PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { scopedToHost } from '@aglyn/tenant-data-admin/server/organizations'\n\n/**\n * The CRM's `pipeline` index (AGL-3080): the organization's deal pipelines\n * and their stages, as another plugin reads them — an automation drafted by\n * the AI names a stage a deal moves to — without knowing where pipelines are\n * stored or what an archived one looks like.\n *\n * Org-scoped (`orgId` required; a site-only scope answers nothing). With a\n * `hostId`, only the pipelines that site can see — the ones shared with it\n * or with every site — which is the set that site's CRM offers. An archived\n * pipeline is left out of every answer.\n *\n * `name` is the pipeline's, or `Untitled pipeline` for one saved without,\n * which is what the CRM itself calls it. A pipeline's `facts`:\n * `stages: Array<{ id: string, name: string }>`, in the pipeline's order,\n * each stage that has an id (a stage saved without a name has `name: ''`).\n */\n\n/** What the CRM calls a pipeline saved without a name. */\nexport const UNTITLED_PIPELINE = 'Untitled pipeline'\n\nconst PIPELINE_FIELDS = ['name', 'stages', 'archivedAt'] as const\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\nfunction pipelineRecord(\n id: string,\n data: Record<string, unknown> | undefined,\n): PluginIndexedRecord | null {\n if (!data || Number(data['archivedAt']) > 0) return null\n const stages = (Array.isArray(data['stages']) ? data['stages'] : [])\n .map((stage) => {\n const entry = (stage ?? {}) as Record<string, unknown>\n return { id: str(entry['id']), name: str(entry['name']) }\n })\n .filter((stage) => stage.id)\n return { id, name: str(data['name']) || UNTITLED_PIPELINE, facts: { stages } }\n}\n\nfunction pipelineCollection(orgId: string): FirebaseFirestore.CollectionReference {\n return firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('pipelines')\n}\n\nexport const pipelineRecordIndex: PluginRecordIndex = {\n async list({ orgId, hostId, limit }) {\n if (!orgId || limit <= 0) return { records: [], truncated: false }\n const collection = pipelineCollection(orgId)\n // One more than asked, so `truncated` is a fact rather than a guess; the\n // archived rows are filtered after the read.\n const snapshot = await (hostId ? scopedToHost(collection, hostId) : collection)\n .select(...PIPELINE_FIELDS)\n .limit(limit + 1)\n .get()\n const records = snapshot.docs\n .slice(0, limit)\n .map((doc) => pipelineRecord(doc.id, doc.data()))\n .filter((record): record is PluginIndexedRecord => record !== null)\n return { records, truncated: snapshot.docs.length > limit }\n },\n async get({ orgId, hostId, id }) {\n if (!orgId || !id) return null\n const snapshot = await pipelineCollection(orgId).doc(id).get()\n const data = snapshot.exists ? snapshot.data() : undefined\n // A site asks about the pipelines it can see, and no other.\n if (hostId && !visibleToHost(data?.['visibleTo'] as string[] | undefined, hostId)) return null\n return pipelineRecord(snapshot.id, data)\n },\n}\n"],"names":["visibleToHost","firebaseAdmin","scopedToHost","UNTITLED_PIPELINE","PIPELINE_FIELDS","str","value","trim","pipelineRecord","id","data","Number","stages","Array","isArray","map","stage","entry","name","filter","facts","pipelineCollection","orgId","app","firestore","collection","doc","pipelineRecordIndex","list","hostId","limit","records","truncated","snapshot","select","get","docs","slice","record","length","exists","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD,SAASA,aAAa,QAAQ,sCAAqC;AACnE,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,YAAY,QAAQ,gDAA+C;AAE5E;;;;;;;;;;;;;;;CAeC,GAED,wDAAwD,GACxD,OAAO,MAAMC,oBAAoB,oBAAmB;AAEpD,MAAMC,kBAAkB;IAAC;IAAQ;IAAU;CAAa;AAExD,MAAMC,MAAM,CAACC,QAA4B,OAAOA,UAAU,WAAWA,MAAMC,IAAI,KAAK;AAEpF,SAASC,eACPC,EAAU,EACVC,IAAyC;IAEzC,IAAI,CAACA,QAAQC,OAAOD,IAAI,CAAC,aAAa,IAAI,GAAG,OAAO;IACpD,MAAME,SAAS,AAACC,CAAAA,MAAMC,OAAO,CAACJ,IAAI,CAAC,SAAS,IAAIA,IAAI,CAAC,SAAS,GAAG,EAAE,AAAD,EAC/DK,GAAG,CAAC,CAACC;QACJ,MAAMC,QAASD,gBAAAA,QAAS,CAAC;QACzB,OAAO;YAAEP,IAAIJ,IAAIY,KAAK,CAAC,KAAK;YAAGC,MAAMb,IAAIY,KAAK,CAAC,OAAO;QAAE;IAC1D,GACCE,MAAM,CAAC,CAACH,QAAUA,MAAMP,EAAE;IAC7B,OAAO;QAAEA;QAAIS,MAAMb,IAAIK,IAAI,CAAC,OAAO,KAAKP;QAAmBiB,OAAO;YAAER;QAAO;IAAE;AAC/E;AAEA,SAASS,mBAAmBC,KAAa;IACvC,OAAOrB,cAAcsB,GAAG,GAAGC,SAAS,GAAGC,UAAU,CAAC,QAAQC,GAAG,CAACJ,OAAOG,UAAU,CAAC;AAClF;AAEA,OAAO,MAAME,sBAAyC;IACpD,MAAMC,MAAK,EAAEN,KAAK,EAAEO,MAAM,EAAEC,KAAK,EAAE;QACjC,IAAI,CAACR,SAASQ,SAAS,GAAG,OAAO;YAAEC,SAAS,EAAE;YAAEC,WAAW;QAAM;QACjE,MAAMP,aAAaJ,mBAAmBC;QACtC,yEAAyE;QACzE,6CAA6C;QAC7C,MAAMW,WAAW,MAAM,AAACJ,CAAAA,SAAS3B,aAAauB,YAAYI,UAAUJ,UAAS,EAC1ES,MAAM,IAAI9B,iBACV0B,KAAK,CAACA,QAAQ,GACdK,GAAG;QACN,MAAMJ,UAAUE,SAASG,IAAI,CAC1BC,KAAK,CAAC,GAAGP,OACTf,GAAG,CAAC,CAACW,MAAQlB,eAAekB,IAAIjB,EAAE,EAAEiB,IAAIhB,IAAI,KAC5CS,MAAM,CAAC,CAACmB,SAA0CA,WAAW;QAChE,OAAO;YAAEP;YAASC,WAAWC,SAASG,IAAI,CAACG,MAAM,GAAGT;QAAM;IAC5D;IACA,MAAMK,KAAI,EAAEb,KAAK,EAAEO,MAAM,EAAEpB,EAAE,EAAE;QAC7B,IAAI,CAACa,SAAS,CAACb,IAAI,OAAO;QAC1B,MAAMwB,WAAW,MAAMZ,mBAAmBC,OAAOI,GAAG,CAACjB,IAAI0B,GAAG;QAC5D,MAAMzB,OAAOuB,SAASO,MAAM,GAAGP,SAASvB,IAAI,KAAK+B;QACjD,4DAA4D;QAC5D,IAAIZ,UAAU,CAAC7B,cAAcU,wBAAAA,IAAM,CAAC,YAAY,EAA0BmB,SAAS,OAAO;QAC1F,OAAOrB,eAAeyB,SAASxB,EAAE,EAAEC;IACrC;AACF,EAAC"}
@@ -15,11 +15,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { registerPluginRecordFactsReader } from "@aglyn/aglyn/plugin-manager/plugin-record-facts";
18
- import { CRM_COLLECTIONS, contactPrimaryGroup, crmReadTokens, isOrgWideMember } from "@aglyn/aglyn/server";
18
+ import { CRM_COLLECTIONS, crmReadTokens, isOrgWideMember } from "@aglyn/aglyn/server";
19
19
  import { consentGroupForSite, firebaseAdmin, getOrgDoc, memberHasOrgPermission, readLeadForHost, resolveOrgIdForHost, resolveOrgMembership } from "@aglyn/tenant-data-admin";
20
20
  import { BUNDLE_ID } from "../constants/bundle-common.js";
21
21
  import { CRM_IMPORT_FACTS_RESOURCE, CRM_RECORD_FACTS_RESOURCES, companyFacts, contactFacts, dealFacts, importFacts, isCrmImportFactsCollection, leadFacts } from "../model/record-facts.js";
22
22
  import { crmSuiteRefusal } from "./suite-gate.js";
23
+ import { contactPrimaryGroup } from "../model/contact-holder.js";
23
24
  /** The org collection a contact lives in, beside the CRM's own. */ const CONTACTS_COLLECTION = 'contacts';
24
25
  /** Logged activities read per record before visibility and the timeline cut. */ export const CRM_FACTS_ACTIVITIES_READ = 40;
25
26
  /** Tasks read per record, the record page's own window. */ export const CRM_FACTS_TASKS_READ = 50;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-facts.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 registerPluginRecordFactsReader,\n type PluginRecordFactsRead,\n type PluginRecordFactsReader,\n type PluginRecordFactsRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-facts'\nimport {\n CRM_COLLECTIONS,\n contactPrimaryGroup,\n crmReadTokens,\n isOrgWideMember,\n type ConsentGroup,\n type ContactFieldDefinition,\n type CrmActivity,\n type CrmCompany,\n type CrmDeal,\n type CrmPipeline,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgDoc,\n memberHasOrgPermission,\n readLeadForHost,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n CRM_IMPORT_FACTS_RESOURCE,\n CRM_RECORD_FACTS_RESOURCES,\n companyFacts,\n contactFacts,\n dealFacts,\n importFacts,\n isCrmImportFactsCollection,\n leadFacts,\n type CrmRecordFactsKind,\n} from '../model/record-facts'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * THE CRM'S READERS ON THE CORE'S RECORD-FACTS SEAM (AGL-2917).\n *\n * Another plugin asks for a contact, a company, a deal, a lead or an import's\n * field catalog by resource name, and gets the facts `model/record-facts.ts`\n * reports — read here with the rules every other CRM door applies, for the\n * member the caller names:\n *\n * 1. the site belongs to the organization;\n * 2. the member reaches it — an org-wide member at the organization level, a\n * member of that site under it — and holds `data.manage`, the key the\n * whole CRM is gated on. A verified staff caller passes, as at every CRM\n * route;\n * 3. the organization's plan carries the CRM, asked after the caller is\n * known, the order `suite-gate.ts` gives;\n * 4. the record exists and is visible where it is read: under a site, its\n * `visibleTo` meets the site's read tokens, as the console's listeners\n * filter; at the organization level every row is the member's;\n * 5. what hangs off it — its logged activity, its tasks, its deals — is held\n * to the same visibility, row by row.\n *\n * A caller in the same process skips the plugin API dispatcher, and with it\n * the dispatcher's per-site enablement, release flag, lockdown and rate\n * limit; those are the caller's to establish before it asks (the AI plugin's\n * CRM admission does). Nothing here writes.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\ntype Refusal = { ok: false; status: 400 | 403 | 404; error: string }\n\n/** The org collection a contact lives in, beside the CRM's own. */\nconst CONTACTS_COLLECTION = 'contacts'\n\n/** Logged activities read per record before visibility and the timeline cut. */\nexport const CRM_FACTS_ACTIVITIES_READ = 40\n/** Tasks read per record, the record page's own window. */\nexport const CRM_FACTS_TASKS_READ = 50\n/** Deals read per contact or company. */\nexport const CRM_FACTS_DEALS_READ = 20\n/** Custom field definitions an import catalog reads. */\nexport const CRM_FACTS_FIELD_DEFINITIONS_READ = 100\n\nexport const CRM_FACTS_UNKNOWN_SITE = 'Unknown site'\nexport const CRM_FACTS_NOT_A_MEMBER = 'You are not a member of that organization'\nexport const CRM_FACTS_SITE_REFUSAL = 'Reading CRM records requires the data.manage permission on this site'\nexport const CRM_FACTS_ORG_REFUSAL =\n 'Reading CRM records across the organization requires the data.manage permission for the whole organization'\nexport const CRM_FACTS_LEAD_NEEDS_SITE = 'A lead is read on the site that captured it'\n\nconst NOT_FOUND: Record<CrmRecordFactsKind | 'import', string> = {\n contact: 'This contact could not be found. It may have been deleted.',\n company: 'This company could not be found. It may have been deleted.',\n deal: 'This deal could not be found. It may have been deleted.',\n lead: 'This lead could not be found. It may have been removed.',\n import: 'There is no such import.',\n}\n\n/** Where a read runs, once the caller has been admitted. */\ninterface CrmFactsScope {\n orgId: string\n hostId: string | null\n org: Data\n /** The site's consent group under a site; `null` at the organization level. */\n group: ConsentGroup | null\n /** Whether a row's `visibleTo` is readable here. */\n visible: (visibleTo: unknown) => boolean\n}\n\nfunction firestore(): Firestore {\n return firebaseAdmin.app().firestore()\n}\n\n/**\n * Rules 1 to 3: the site, the member and the plan. A refusal names what the\n * member lacks in the words the console's own CRM doors use.\n */\nexport async function crmFactsScope(request: PluginRecordFactsRequest): Promise<CrmFactsScope | Refusal> {\n const orgId = String(request.orgId ?? '').trim()\n const hostId = request.hostId ? String(request.hostId).trim() : null\n if (!orgId) return { ok: false, status: 400, error: 'Open a workspace first' }\n if (hostId && (await resolveOrgIdForHost(hostId)) !== orgId) {\n return { ok: false, status: 404, error: CRM_FACTS_UNKNOWN_SITE }\n }\n if (!request.staff) {\n const membership = await resolveOrgMembership(request.uid, orgId).catch(() => null)\n const member = membership?.orgId === orgId ? membership.member : null\n if (!member) return { ok: false, status: 403, error: CRM_FACTS_NOT_A_MEMBER }\n const reaches = hostId\n ? isOrgWideMember(member) || Boolean(member.hostAccess?.[hostId])\n : isOrgWideMember(member)\n const allowed = reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return { ok: false, status: 403, error: hostId ? CRM_FACTS_SITE_REFUSAL : CRM_FACTS_ORG_REFUSAL }\n }\n }\n const org = ((request.org as Data | null) ?? ((await getOrgDoc(orgId)) as Data | null)) || null\n if (!org) return { ok: false, status: 404, error: 'Unknown organization' }\n const suite = crmSuiteRefusal(org, 'Reading a CRM record')\n if (suite) return { ok: false, status: 403, error: suite.body.error }\n if (!hostId) return { orgId, hostId: null, org, group: null, visible: () => true }\n const group = await consentGroupForSite(hostId, org)\n const readable = new Set<string>(crmReadTokens(group))\n return {\n orgId,\n hostId,\n org,\n group,\n visible: (visibleTo) =>\n Array.isArray(visibleTo) && visibleTo.some((token) => readable.has(String(token))),\n }\n}\n\nconst orgCollection = (orgId: string, name: string) =>\n firestore().collection('orgs').doc(orgId).collection(name)\n\n/** Rows of an org collection naming one record, held to the scope's visibility. */\nasync function rowsNaming<T>(\n scope: CrmFactsScope,\n input: { collection: string; field: string; id: string; orderBy: string; direction: 'asc' | 'desc'; limit: number },\n): Promise<T[]> {\n const snapshot = await orgCollection(scope.orgId, input.collection)\n .where(input.field, '==', input.id)\n .orderBy(input.orderBy, input.direction)\n .limit(input.limit)\n .get()\n return snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Data)\n .filter((row) => scope.visible(row['visibleTo'])) as T[]\n}\n\n/** The pipelines some deals name, by id, read once each. */\nasync function pipelinesFor(scope: CrmFactsScope, deals: ReadonlyArray<Partial<CrmDeal>>): Promise<Map<string, CrmPipeline>> {\n const ids = [...new Set(deals.map((deal) => String(deal.pipelineId ?? '')).filter(Boolean))]\n const snapshots = await Promise.all(ids.map((id) => orgCollection(scope.orgId, CRM_COLLECTIONS.pipelines).doc(id).get()))\n return new Map(\n snapshots\n .filter((snapshot) => snapshot.exists)\n .map((snapshot) => [snapshot.id, (snapshot.data() ?? {}) as CrmPipeline]),\n )\n}\n\n/** A record document the scope may read, or `null`. */\nasync function visibleDoc(scope: CrmFactsScope, collection: string, id: string): Promise<Data | null> {\n if (!id) return null\n const snapshot = await orgCollection(scope.orgId, collection).doc(id).get()\n const data = snapshot.exists ? ((snapshot.data() ?? {}) as Data) : null\n return data && scope.visible(data['visibleTo']) ? data : null\n}\n\nconst refused = (kind: CrmRecordFactsKind | 'import', status: 400 | 404 = 404): Refusal => ({\n ok: false,\n status,\n error: NOT_FOUND[kind],\n})\n\n/** A reader that admits the caller, then reads the record. */\nfunction reader(read: (scope: CrmFactsScope, request: PluginRecordFactsRequest) => Promise<PluginRecordFactsRead>): PluginRecordFactsReader {\n return {\n read: async (request) => {\n const scope = await crmFactsScope(request)\n if ('ok' in scope) return scope\n return read(scope, request)\n },\n }\n}\n\nexport const crmContactFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const row = await visibleDoc(scope, CONTACTS_COLLECTION, id)\n if (!row) return refused('contact')\n const group = scope.group ?? contactPrimaryGroup(row, scope.org)\n const naming = { field: 'contactId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: { ...contactFacts({ row, group, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }) },\n }\n})\n\nexport const crmCompanyFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const company = await visibleDoc(scope, CRM_COLLECTIONS.companies, id)\n if (!company) return refused('company')\n const naming = { field: 'companyId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: {\n ...companyFacts({ company: company as Partial<CrmCompany>, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }),\n },\n }\n})\n\nexport const crmDealFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const deal = (await visibleDoc(scope, CRM_COLLECTIONS.deals, id)) as (Partial<CrmDeal> & Data) | null\n if (!deal) return refused('deal')\n const naming = { field: 'dealId', id }\n const [activities, tasks, pipelines] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n pipelinesFor(scope, [deal]),\n ])\n return {\n ok: true,\n facts: {\n ...dealFacts({\n deal,\n pipeline: pipelines.get(String(deal.pipelineId ?? '')) ?? null,\n activities,\n tasks,\n nowMs: request.now.getTime(),\n }),\n },\n }\n})\n\nexport const crmLeadFactsReader = reader(async (scope, request) => {\n if (!scope.hostId) return { ok: false, status: 400, error: CRM_FACTS_LEAD_NEEDS_SITE }\n const id = String(request.id ?? '').trim()\n if (!id) return refused('lead')\n const snapshot = await readLeadForHost(scope.hostId, id)\n if (!snapshot) return refused('lead')\n const activities = await rowsNaming<CrmActivity>(scope, {\n field: 'leadId',\n id,\n collection: CRM_COLLECTIONS.activities,\n orderBy: 'atMs',\n direction: 'desc',\n limit: CRM_FACTS_ACTIVITIES_READ,\n })\n return { ok: true, facts: { ...leadFacts({ lead: (snapshot.data() ?? {}) as Data, activities }) } }\n})\n\nexport const crmImportFactsReader = reader(async (scope, request) => {\n const collection = String(request.id ?? '').trim()\n if (!isCrmImportFactsCollection(collection)) return refused('import', 400)\n const snapshot = await orgCollection(scope.orgId, CRM_COLLECTIONS.contactFields)\n .limit(CRM_FACTS_FIELD_DEFINITIONS_READ)\n .get()\n const definitions = snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Partial<ContactFieldDefinition> & Data)\n .filter((definition) => scope.visible(definition['visibleTo']))\n return { ok: true, facts: { ...importFacts(collection, definitions) } }\n})\n\n/** Every reader this plugin registers, by resource name. */\nexport const CRM_FACTS_READERS: Readonly<Record<string, PluginRecordFactsReader>> = {\n [CRM_RECORD_FACTS_RESOURCES.contact]: crmContactFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.company]: crmCompanyFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.deal]: crmDealFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.lead]: crmLeadFactsReader,\n [CRM_IMPORT_FACTS_RESOURCE]: crmImportFactsReader,\n}\n\n/**\n * Registers the readers; the console surface calls it, the one process that\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCrmRecordFactsReaders(): void {\n for (const [resource, factsReader] of Object.entries(CRM_FACTS_READERS)) {\n registerPluginRecordFactsReader(resource, factsReader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["registerPluginRecordFactsReader","CRM_COLLECTIONS","contactPrimaryGroup","crmReadTokens","isOrgWideMember","consentGroupForSite","firebaseAdmin","getOrgDoc","memberHasOrgPermission","readLeadForHost","resolveOrgIdForHost","resolveOrgMembership","BUNDLE_ID","CRM_IMPORT_FACTS_RESOURCE","CRM_RECORD_FACTS_RESOURCES","companyFacts","contactFacts","dealFacts","importFacts","isCrmImportFactsCollection","leadFacts","crmSuiteRefusal","CONTACTS_COLLECTION","CRM_FACTS_ACTIVITIES_READ","CRM_FACTS_TASKS_READ","CRM_FACTS_DEALS_READ","CRM_FACTS_FIELD_DEFINITIONS_READ","CRM_FACTS_UNKNOWN_SITE","CRM_FACTS_NOT_A_MEMBER","CRM_FACTS_SITE_REFUSAL","CRM_FACTS_ORG_REFUSAL","CRM_FACTS_LEAD_NEEDS_SITE","NOT_FOUND","contact","company","deal","lead","import","firestore","app","crmFactsScope","request","orgId","String","trim","hostId","ok","status","error","staff","member","membership","uid","catch","reaches","Boolean","hostAccess","allowed","org","suite","body","group","visible","readable","Set","visibleTo","Array","isArray","some","token","has","orgCollection","name","collection","doc","rowsNaming","scope","input","snapshot","where","field","id","orderBy","direction","limit","get","docs","map","data","filter","row","pipelinesFor","deals","ids","pipelineId","snapshots","Promise","all","pipelines","Map","exists","visibleDoc","refused","kind","reader","read","crmContactFactsReader","naming","activities","tasks","facts","nowMs","now","getTime","crmCompanyFactsReader","companies","crmDealFactsReader","pipeline","crmLeadFactsReader","crmImportFactsReader","contactFields","definitions","definition","CRM_FACTS_READERS","registerCrmRecordFactsReaders","resource","factsReader","Object","entries","pluginId"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-facts.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 registerPluginRecordFactsReader,\n type PluginRecordFactsRead,\n type PluginRecordFactsReader,\n type PluginRecordFactsRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-facts'\nimport {\n CRM_COLLECTIONS,\n crmReadTokens,\n isOrgWideMember,\n type ConsentGroup,\n type ContactFieldDefinition,\n type CrmActivity,\n type CrmCompany,\n type CrmDeal,\n type CrmPipeline,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgDoc,\n memberHasOrgPermission,\n readLeadForHost,\n resolveOrgIdForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n CRM_IMPORT_FACTS_RESOURCE,\n CRM_RECORD_FACTS_RESOURCES,\n companyFacts,\n contactFacts,\n dealFacts,\n importFacts,\n isCrmImportFactsCollection,\n leadFacts,\n type CrmRecordFactsKind,\n} from '../model/record-facts'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/**\n * THE CRM'S READERS ON THE CORE'S RECORD-FACTS SEAM (AGL-2917).\n *\n * Another plugin asks for a contact, a company, a deal, a lead or an import's\n * field catalog by resource name, and gets the facts `model/record-facts.ts`\n * reports — read here with the rules every other CRM door applies, for the\n * member the caller names:\n *\n * 1. the site belongs to the organization;\n * 2. the member reaches it — an org-wide member at the organization level, a\n * member of that site under it — and holds `data.manage`, the key the\n * whole CRM is gated on. A verified staff caller passes, as at every CRM\n * route;\n * 3. the organization's plan carries the CRM, asked after the caller is\n * known, the order `suite-gate.ts` gives;\n * 4. the record exists and is visible where it is read: under a site, its\n * `visibleTo` meets the site's read tokens, as the console's listeners\n * filter; at the organization level every row is the member's;\n * 5. what hangs off it — its logged activity, its tasks, its deals — is held\n * to the same visibility, row by row.\n *\n * A caller in the same process skips the plugin API dispatcher, and with it\n * the dispatcher's per-site enablement, release flag, lockdown and rate\n * limit; those are the caller's to establish before it asks (the AI plugin's\n * CRM admission does). Nothing here writes.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Data = Record<string, unknown>\ntype Refusal = { ok: false; status: 400 | 403 | 404; error: string }\n\n/** The org collection a contact lives in, beside the CRM's own. */\nconst CONTACTS_COLLECTION = 'contacts'\n\n/** Logged activities read per record before visibility and the timeline cut. */\nexport const CRM_FACTS_ACTIVITIES_READ = 40\n/** Tasks read per record, the record page's own window. */\nexport const CRM_FACTS_TASKS_READ = 50\n/** Deals read per contact or company. */\nexport const CRM_FACTS_DEALS_READ = 20\n/** Custom field definitions an import catalog reads. */\nexport const CRM_FACTS_FIELD_DEFINITIONS_READ = 100\n\nexport const CRM_FACTS_UNKNOWN_SITE = 'Unknown site'\nexport const CRM_FACTS_NOT_A_MEMBER = 'You are not a member of that organization'\nexport const CRM_FACTS_SITE_REFUSAL = 'Reading CRM records requires the data.manage permission on this site'\nexport const CRM_FACTS_ORG_REFUSAL =\n 'Reading CRM records across the organization requires the data.manage permission for the whole organization'\nexport const CRM_FACTS_LEAD_NEEDS_SITE = 'A lead is read on the site that captured it'\n\nconst NOT_FOUND: Record<CrmRecordFactsKind | 'import', string> = {\n contact: 'This contact could not be found. It may have been deleted.',\n company: 'This company could not be found. It may have been deleted.',\n deal: 'This deal could not be found. It may have been deleted.',\n lead: 'This lead could not be found. It may have been removed.',\n import: 'There is no such import.',\n}\n\n/** Where a read runs, once the caller has been admitted. */\ninterface CrmFactsScope {\n orgId: string\n hostId: string | null\n org: Data\n /** The site's consent group under a site; `null` at the organization level. */\n group: ConsentGroup | null\n /** Whether a row's `visibleTo` is readable here. */\n visible: (visibleTo: unknown) => boolean\n}\n\nfunction firestore(): Firestore {\n return firebaseAdmin.app().firestore()\n}\n\n/**\n * Rules 1 to 3: the site, the member and the plan. A refusal names what the\n * member lacks in the words the console's own CRM doors use.\n */\nexport async function crmFactsScope(request: PluginRecordFactsRequest): Promise<CrmFactsScope | Refusal> {\n const orgId = String(request.orgId ?? '').trim()\n const hostId = request.hostId ? String(request.hostId).trim() : null\n if (!orgId) return { ok: false, status: 400, error: 'Open a workspace first' }\n if (hostId && (await resolveOrgIdForHost(hostId)) !== orgId) {\n return { ok: false, status: 404, error: CRM_FACTS_UNKNOWN_SITE }\n }\n if (!request.staff) {\n const membership = await resolveOrgMembership(request.uid, orgId).catch(() => null)\n const member = membership?.orgId === orgId ? membership.member : null\n if (!member) return { ok: false, status: 403, error: CRM_FACTS_NOT_A_MEMBER }\n const reaches = hostId\n ? isOrgWideMember(member) || Boolean(member.hostAccess?.[hostId])\n : isOrgWideMember(member)\n const allowed = reaches && (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return { ok: false, status: 403, error: hostId ? CRM_FACTS_SITE_REFUSAL : CRM_FACTS_ORG_REFUSAL }\n }\n }\n const org = ((request.org as Data | null) ?? ((await getOrgDoc(orgId)) as Data | null)) || null\n if (!org) return { ok: false, status: 404, error: 'Unknown organization' }\n const suite = crmSuiteRefusal(org, 'Reading a CRM record')\n if (suite) return { ok: false, status: 403, error: suite.body.error }\n if (!hostId) return { orgId, hostId: null, org, group: null, visible: () => true }\n const group = await consentGroupForSite(hostId, org)\n const readable = new Set<string>(crmReadTokens(group))\n return {\n orgId,\n hostId,\n org,\n group,\n visible: (visibleTo) =>\n Array.isArray(visibleTo) && visibleTo.some((token) => readable.has(String(token))),\n }\n}\n\nconst orgCollection = (orgId: string, name: string) =>\n firestore().collection('orgs').doc(orgId).collection(name)\n\n/** Rows of an org collection naming one record, held to the scope's visibility. */\nasync function rowsNaming<T>(\n scope: CrmFactsScope,\n input: { collection: string; field: string; id: string; orderBy: string; direction: 'asc' | 'desc'; limit: number },\n): Promise<T[]> {\n const snapshot = await orgCollection(scope.orgId, input.collection)\n .where(input.field, '==', input.id)\n .orderBy(input.orderBy, input.direction)\n .limit(input.limit)\n .get()\n return snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Data)\n .filter((row) => scope.visible(row['visibleTo'])) as T[]\n}\n\n/** The pipelines some deals name, by id, read once each. */\nasync function pipelinesFor(scope: CrmFactsScope, deals: ReadonlyArray<Partial<CrmDeal>>): Promise<Map<string, CrmPipeline>> {\n const ids = [...new Set(deals.map((deal) => String(deal.pipelineId ?? '')).filter(Boolean))]\n const snapshots = await Promise.all(ids.map((id) => orgCollection(scope.orgId, CRM_COLLECTIONS.pipelines).doc(id).get()))\n return new Map(\n snapshots\n .filter((snapshot) => snapshot.exists)\n .map((snapshot) => [snapshot.id, (snapshot.data() ?? {}) as CrmPipeline]),\n )\n}\n\n/** A record document the scope may read, or `null`. */\nasync function visibleDoc(scope: CrmFactsScope, collection: string, id: string): Promise<Data | null> {\n if (!id) return null\n const snapshot = await orgCollection(scope.orgId, collection).doc(id).get()\n const data = snapshot.exists ? ((snapshot.data() ?? {}) as Data) : null\n return data && scope.visible(data['visibleTo']) ? data : null\n}\n\nconst refused = (kind: CrmRecordFactsKind | 'import', status: 400 | 404 = 404): Refusal => ({\n ok: false,\n status,\n error: NOT_FOUND[kind],\n})\n\n/** A reader that admits the caller, then reads the record. */\nfunction reader(read: (scope: CrmFactsScope, request: PluginRecordFactsRequest) => Promise<PluginRecordFactsRead>): PluginRecordFactsReader {\n return {\n read: async (request) => {\n const scope = await crmFactsScope(request)\n if ('ok' in scope) return scope\n return read(scope, request)\n },\n }\n}\n\nexport const crmContactFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const row = await visibleDoc(scope, CONTACTS_COLLECTION, id)\n if (!row) return refused('contact')\n const group = scope.group ?? contactPrimaryGroup(row, scope.org)\n const naming = { field: 'contactId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: { ...contactFacts({ row, group, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }) },\n }\n})\n\nexport const crmCompanyFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const company = await visibleDoc(scope, CRM_COLLECTIONS.companies, id)\n if (!company) return refused('company')\n const naming = { field: 'companyId', id }\n const [activities, tasks, deals] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n rowsNaming<CrmDeal>(scope, { ...naming, collection: CRM_COLLECTIONS.deals, orderBy: 'updatedAt', direction: 'desc', limit: CRM_FACTS_DEALS_READ }),\n ])\n const pipelines = await pipelinesFor(scope, deals)\n return {\n ok: true,\n facts: {\n ...companyFacts({ company: company as Partial<CrmCompany>, activities, tasks, deals, pipelines, nowMs: request.now.getTime() }),\n },\n }\n})\n\nexport const crmDealFactsReader = reader(async (scope, request) => {\n const id = String(request.id ?? '').trim()\n const deal = (await visibleDoc(scope, CRM_COLLECTIONS.deals, id)) as (Partial<CrmDeal> & Data) | null\n if (!deal) return refused('deal')\n const naming = { field: 'dealId', id }\n const [activities, tasks, pipelines] = await Promise.all([\n rowsNaming<CrmActivity>(scope, { ...naming, collection: CRM_COLLECTIONS.activities, orderBy: 'atMs', direction: 'desc', limit: CRM_FACTS_ACTIVITIES_READ }),\n rowsNaming<CrmTask>(scope, { ...naming, collection: CRM_COLLECTIONS.tasks, orderBy: 'dueAtMs', direction: 'asc', limit: CRM_FACTS_TASKS_READ }),\n pipelinesFor(scope, [deal]),\n ])\n return {\n ok: true,\n facts: {\n ...dealFacts({\n deal,\n pipeline: pipelines.get(String(deal.pipelineId ?? '')) ?? null,\n activities,\n tasks,\n nowMs: request.now.getTime(),\n }),\n },\n }\n})\n\nexport const crmLeadFactsReader = reader(async (scope, request) => {\n if (!scope.hostId) return { ok: false, status: 400, error: CRM_FACTS_LEAD_NEEDS_SITE }\n const id = String(request.id ?? '').trim()\n if (!id) return refused('lead')\n const snapshot = await readLeadForHost(scope.hostId, id)\n if (!snapshot) return refused('lead')\n const activities = await rowsNaming<CrmActivity>(scope, {\n field: 'leadId',\n id,\n collection: CRM_COLLECTIONS.activities,\n orderBy: 'atMs',\n direction: 'desc',\n limit: CRM_FACTS_ACTIVITIES_READ,\n })\n return { ok: true, facts: { ...leadFacts({ lead: (snapshot.data() ?? {}) as Data, activities }) } }\n})\n\nexport const crmImportFactsReader = reader(async (scope, request) => {\n const collection = String(request.id ?? '').trim()\n if (!isCrmImportFactsCollection(collection)) return refused('import', 400)\n const snapshot = await orgCollection(scope.orgId, CRM_COLLECTIONS.contactFields)\n .limit(CRM_FACTS_FIELD_DEFINITIONS_READ)\n .get()\n const definitions = snapshot.docs\n .map((doc) => (doc.data() ?? {}) as Partial<ContactFieldDefinition> & Data)\n .filter((definition) => scope.visible(definition['visibleTo']))\n return { ok: true, facts: { ...importFacts(collection, definitions) } }\n})\n\n/** Every reader this plugin registers, by resource name. */\nexport const CRM_FACTS_READERS: Readonly<Record<string, PluginRecordFactsReader>> = {\n [CRM_RECORD_FACTS_RESOURCES.contact]: crmContactFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.company]: crmCompanyFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.deal]: crmDealFactsReader,\n [CRM_RECORD_FACTS_RESOURCES.lead]: crmLeadFactsReader,\n [CRM_IMPORT_FACTS_RESOURCE]: crmImportFactsReader,\n}\n\n/**\n * Registers the readers; the console surface calls it, the one process that\n * runs AI jobs (AGL-3026). Idempotent: a second call replaces the first.\n */\nexport function registerCrmRecordFactsReaders(): void {\n for (const [resource, factsReader] of Object.entries(CRM_FACTS_READERS)) {\n registerPluginRecordFactsReader(resource, factsReader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["registerPluginRecordFactsReader","CRM_COLLECTIONS","crmReadTokens","isOrgWideMember","consentGroupForSite","firebaseAdmin","getOrgDoc","memberHasOrgPermission","readLeadForHost","resolveOrgIdForHost","resolveOrgMembership","BUNDLE_ID","CRM_IMPORT_FACTS_RESOURCE","CRM_RECORD_FACTS_RESOURCES","companyFacts","contactFacts","dealFacts","importFacts","isCrmImportFactsCollection","leadFacts","crmSuiteRefusal","contactPrimaryGroup","CONTACTS_COLLECTION","CRM_FACTS_ACTIVITIES_READ","CRM_FACTS_TASKS_READ","CRM_FACTS_DEALS_READ","CRM_FACTS_FIELD_DEFINITIONS_READ","CRM_FACTS_UNKNOWN_SITE","CRM_FACTS_NOT_A_MEMBER","CRM_FACTS_SITE_REFUSAL","CRM_FACTS_ORG_REFUSAL","CRM_FACTS_LEAD_NEEDS_SITE","NOT_FOUND","contact","company","deal","lead","import","firestore","app","crmFactsScope","request","orgId","String","trim","hostId","ok","status","error","staff","member","membership","uid","catch","reaches","Boolean","hostAccess","allowed","org","suite","body","group","visible","readable","Set","visibleTo","Array","isArray","some","token","has","orgCollection","name","collection","doc","rowsNaming","scope","input","snapshot","where","field","id","orderBy","direction","limit","get","docs","map","data","filter","row","pipelinesFor","deals","ids","pipelineId","snapshots","Promise","all","pipelines","Map","exists","visibleDoc","refused","kind","reader","read","crmContactFactsReader","naming","activities","tasks","facts","nowMs","now","getTime","crmCompanyFactsReader","companies","crmDealFactsReader","pipeline","crmLeadFactsReader","crmImportFactsReader","contactFields","definitions","definition","CRM_FACTS_READERS","registerCrmRecordFactsReaders","resource","factsReader","Object","entries","pluginId"],"mappings":"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"}