@agent-native/core 0.123.0 → 0.123.1

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 (263) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  6. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  7. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  8. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  9. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  10. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  11. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  12. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  13. package/corpus/templates/crm/.env.example +5 -0
  14. package/corpus/templates/crm/AGENTS.md +82 -0
  15. package/corpus/templates/crm/CHANGELOG.md +25 -0
  16. package/corpus/templates/crm/DEVELOPING.md +66 -0
  17. package/corpus/templates/crm/README.md +122 -0
  18. package/corpus/templates/crm/_gitignore +12 -0
  19. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  20. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  21. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  22. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  23. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  24. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  25. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  26. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  27. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  28. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  29. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  30. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  31. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  32. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  33. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  34. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  35. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  36. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  37. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  38. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  39. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  40. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  41. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  42. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  43. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  44. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  45. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  46. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  47. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  48. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  49. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  50. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  51. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  52. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  53. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  54. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  55. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  56. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  57. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  58. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  59. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  60. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  61. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  62. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  63. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  64. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  65. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  66. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  67. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  68. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  69. package/corpus/templates/crm/actions/navigate.ts +54 -0
  70. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  71. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  72. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  73. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  74. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  75. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  76. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  77. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  78. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  79. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  80. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  81. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  82. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  83. package/corpus/templates/crm/actions/run.ts +7 -0
  84. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  85. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  86. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  87. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  88. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  89. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  90. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  91. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  92. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  93. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  94. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  95. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  96. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  97. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  98. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  99. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  100. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  101. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  102. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  103. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  104. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  105. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  106. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  107. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  108. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  109. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  110. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  111. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  112. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  113. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  114. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  115. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  116. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  117. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  118. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  119. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  120. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  121. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  122. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  123. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  124. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  125. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  126. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  127. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  128. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  129. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  130. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  131. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  132. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  133. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  134. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  135. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  136. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  137. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  138. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  139. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  140. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  141. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  142. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  143. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  144. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  145. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  146. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  147. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  148. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  149. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  150. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  151. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  152. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  153. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  154. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  155. package/corpus/templates/crm/app/design-system.ts +3 -0
  156. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  157. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  158. package/corpus/templates/crm/app/global.css +167 -0
  159. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  160. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  161. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  162. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  163. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  164. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  165. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  166. package/corpus/templates/crm/app/lib/types.ts +172 -0
  167. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  168. package/corpus/templates/crm/app/root.tsx +93 -0
  169. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  170. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  171. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  172. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  173. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  174. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  175. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  176. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  177. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  178. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  179. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  180. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  181. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  182. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  183. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  184. package/corpus/templates/crm/app/routes.ts +19 -0
  185. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  186. package/corpus/templates/crm/changelog/2026-07-26-any-list-or-view-can-be-shown-as-a-board-grouped-by-stage-so.md +6 -0
  187. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  188. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  189. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  190. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  191. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  192. package/corpus/templates/crm/changelog/2026-07-26-press-cmd-k-to-jump-to-any-record-list-view-or-setting-and-u.md +6 -0
  193. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  194. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  195. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  196. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  197. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  198. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  199. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  200. package/corpus/templates/crm/components.json +20 -0
  201. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  202. package/corpus/templates/crm/docs/features/README.md +55 -0
  203. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  204. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  205. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  206. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  207. package/corpus/templates/crm/learnings.defaults.md +5 -0
  208. package/corpus/templates/crm/netlify.toml +11 -0
  209. package/corpus/templates/crm/package.json +61 -0
  210. package/corpus/templates/crm/public/favicon.svg +1 -0
  211. package/corpus/templates/crm/public/icon-180.svg +1 -0
  212. package/corpus/templates/crm/public/manifest.json +11 -0
  213. package/corpus/templates/crm/react-router.config.ts +7 -0
  214. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  215. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  216. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  217. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  218. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  219. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  220. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  221. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  222. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  223. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  224. package/corpus/templates/crm/server/db/index.ts +191 -0
  225. package/corpus/templates/crm/server/db/schema.ts +647 -0
  226. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  227. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  228. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  229. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  230. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  231. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  232. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  233. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  234. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  235. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  236. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  237. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  238. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  239. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  240. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  241. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  242. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  243. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  244. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  245. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  246. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  247. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  248. package/corpus/templates/crm/ssr-entry.ts +11 -0
  249. package/corpus/templates/crm/tsconfig.json +24 -0
  250. package/corpus/templates/crm/vite.config.ts +15 -0
  251. package/corpus/templates/crm/vitest.config.ts +5 -0
  252. package/corpus/templates/design/app/i18n-data.ts +12 -12
  253. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  254. package/dist/cli/templates-meta.d.ts.map +1 -1
  255. package/dist/cli/templates-meta.js +12 -0
  256. package/dist/cli/templates-meta.js.map +1 -1
  257. package/dist/collab/routes.d.ts +1 -1
  258. package/dist/observability/routes.d.ts +3 -3
  259. package/dist/resources/handlers.d.ts +1 -1
  260. package/dist/server/realtime-token.d.ts +1 -1
  261. package/dist/server/transcribe-voice.d.ts +1 -1
  262. package/package.json +1 -1
  263. package/src/cli/templates-meta.ts +12 -0
@@ -0,0 +1,1112 @@
1
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
2
+ import { and, asc, desc, eq, exists, inArray, isNull, like } from "drizzle-orm";
3
+
4
+ import type {
5
+ CrmAccessScope,
6
+ CrmRelationship,
7
+ CrmValue,
8
+ } from "../../shared/crm-contract.js";
9
+ import {
10
+ createConnectedCrmAdapter,
11
+ isConnectedCrmProvider,
12
+ } from "../crm/adapter.js";
13
+ import {
14
+ isBoundedCrmValue,
15
+ isSafeCrmMutationFieldName,
16
+ } from "../crm/crm-field-firewall.js";
17
+ import { resolveNativeCrmAccessScope } from "../crm/native-adapter.js";
18
+ import {
19
+ parseCrmAccessScope,
20
+ relatedSummaries,
21
+ scopesAreCompatible,
22
+ type RelatedRecordSummary,
23
+ } from "../crm/read-through.js";
24
+ import { getDb, schema } from "./index.js";
25
+
26
+ const MAX_RECORD_LIMIT = 100;
27
+ const RECORD_DETAIL_LIMIT = 20;
28
+ const MAX_SCOPE_VALIDATIONS = 20;
29
+ const SAFE_VIEW_COLUMNS = new Set([
30
+ "displayName",
31
+ "primaryEmail",
32
+ "domain",
33
+ "stage",
34
+ "ownerName",
35
+ "nextContactAt",
36
+ "remoteUpdatedAt",
37
+ "updatedAt",
38
+ ]);
39
+ const SAFE_VIEW_SORTS = new Set([
40
+ "displayName",
41
+ "stage",
42
+ "ownerName",
43
+ "nextContactAt",
44
+ "remoteUpdatedAt",
45
+ "updatedAt",
46
+ ]);
47
+
48
+ export type CrmListKind =
49
+ | "account"
50
+ | "person"
51
+ | "opportunity"
52
+ | "activity"
53
+ | "task"
54
+ | "custom";
55
+
56
+ type Primitive = string | number | boolean | null;
57
+
58
+ type SavedViewConfig = {
59
+ id: string;
60
+ name: string;
61
+ kind?: CrmListKind;
62
+ dataProgramId?: string;
63
+ query?: string;
64
+ fieldEquals: Record<string, Primitive>;
65
+ columns: string[];
66
+ sort: Array<{ field: string; direction: "asc" | "desc" }>;
67
+ };
68
+
69
+ type ScopeValidationTarget = {
70
+ connectionId: string;
71
+ workspaceConnectionId: string | null;
72
+ provider: string;
73
+ objectType: string;
74
+ };
75
+
76
+ export type CrmScopeResolver = (
77
+ target: ScopeValidationTarget,
78
+ ) => Promise<CrmAccessScope | null>;
79
+
80
+ function decodeCursor(cursor: string | undefined): number {
81
+ if (!cursor) return 0;
82
+ const value = Number.parseInt(cursor, 10);
83
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
84
+ }
85
+
86
+ function page<T>(rows: T[], limit: number) {
87
+ return {
88
+ rows: rows.slice(0, limit),
89
+ nextCursor:
90
+ rows.length > limit ? String(decodeCursor(undefined) + limit) : undefined,
91
+ };
92
+ }
93
+
94
+ function toRecordSummary(
95
+ row: {
96
+ id: string;
97
+ displayName: string;
98
+ kind: string;
99
+ primaryEmail: string | null;
100
+ domain: string | null;
101
+ stage: string | null;
102
+ ownerName: string | null;
103
+ nextContactAt: string | null;
104
+ remoteUpdatedAt: string | null;
105
+ updatedAt: string;
106
+ },
107
+ columns?: string[],
108
+ ) {
109
+ const included = new Set(columns ?? SAFE_VIEW_COLUMNS);
110
+ return {
111
+ id: row.id,
112
+ displayName: row.displayName,
113
+ kind: row.kind,
114
+ ...(included.has("domain") || included.has("primaryEmail")
115
+ ? { subtitle: row.domain ?? row.primaryEmail ?? undefined }
116
+ : {}),
117
+ ...(included.has("ownerName") ? { owner: row.ownerName ?? undefined } : {}),
118
+ ...(included.has("stage") ? { stage: row.stage ?? undefined } : {}),
119
+ ...(included.has("nextContactAt")
120
+ ? { nextStep: row.nextContactAt ?? undefined }
121
+ : {}),
122
+ ...(included.has("remoteUpdatedAt") || included.has("updatedAt")
123
+ ? { updatedAt: row.remoteUpdatedAt ?? row.updatedAt }
124
+ : {}),
125
+ };
126
+ }
127
+
128
+ function parseStoredValue(row: {
129
+ valueType: string;
130
+ stringValue: string | null;
131
+ numberValue: number | null;
132
+ booleanValue: boolean | null;
133
+ jsonValue: string | null;
134
+ }): unknown {
135
+ if (row.booleanValue !== null) return row.booleanValue;
136
+ if (row.numberValue !== null) return row.numberValue;
137
+ if (row.stringValue !== null) return row.stringValue;
138
+ if (!row.jsonValue) return null;
139
+ try {
140
+ return JSON.parse(row.jsonValue) as unknown;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function parsePrimitive(value: unknown): value is Primitive {
147
+ return (
148
+ value === null ||
149
+ typeof value === "boolean" ||
150
+ (typeof value === "number" && Number.isFinite(value)) ||
151
+ (typeof value === "string" && value.length <= 2_000)
152
+ );
153
+ }
154
+
155
+ function parseJsonObject(value: string): Record<string, unknown> | null {
156
+ try {
157
+ const parsed = JSON.parse(value) as unknown;
158
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
159
+ ? (parsed as Record<string, unknown>)
160
+ : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function parseSavedViewConfig(input: {
167
+ id: string;
168
+ name: string;
169
+ kind: string | null;
170
+ filtersJson: string;
171
+ columnsJson: string;
172
+ sortJson: string;
173
+ dataProgramId: string | null;
174
+ }): SavedViewConfig {
175
+ if (
176
+ input.kind !== null &&
177
+ input.kind !== "account" &&
178
+ input.kind !== "person" &&
179
+ input.kind !== "opportunity"
180
+ ) {
181
+ throw new Error("CRM saved view has an unsupported record kind.");
182
+ }
183
+ const filters = parseJsonObject(input.filtersJson);
184
+ if (!filters) throw new Error("CRM saved view filters are invalid.");
185
+ const filterKeys = Object.keys(filters);
186
+ if (filterKeys.some((key) => key !== "query" && key !== "fieldEquals")) {
187
+ throw new Error("CRM saved view contains unsupported filters.");
188
+ }
189
+ const query = filters.query;
190
+ if (
191
+ query !== undefined &&
192
+ (typeof query !== "string" || query.length > 120)
193
+ ) {
194
+ throw new Error("CRM saved view query is invalid.");
195
+ }
196
+ const rawFieldEquals = filters.fieldEquals;
197
+ if (
198
+ rawFieldEquals !== undefined &&
199
+ (!rawFieldEquals ||
200
+ typeof rawFieldEquals !== "object" ||
201
+ Array.isArray(rawFieldEquals))
202
+ ) {
203
+ throw new Error("CRM saved view field filters are invalid.");
204
+ }
205
+ const fieldEquals = Object.entries(
206
+ (rawFieldEquals ?? {}) as Record<string, unknown>,
207
+ );
208
+ if (
209
+ fieldEquals.length > 12 ||
210
+ fieldEquals.some(
211
+ ([fieldName, value]) =>
212
+ !fieldName.trim() || fieldName.length > 120 || !parsePrimitive(value),
213
+ )
214
+ ) {
215
+ throw new Error("CRM saved view field filters must be bounded primitives.");
216
+ }
217
+ const columns = JSON.parse(input.columnsJson) as unknown;
218
+ if (
219
+ !Array.isArray(columns) ||
220
+ columns.length > 20 ||
221
+ columns.some(
222
+ (column) => typeof column !== "string" || !SAFE_VIEW_COLUMNS.has(column),
223
+ )
224
+ ) {
225
+ throw new Error("CRM saved view columns are unsupported.");
226
+ }
227
+ const sort = JSON.parse(input.sortJson) as unknown;
228
+ if (
229
+ !Array.isArray(sort) ||
230
+ sort.length > 2 ||
231
+ sort.some(
232
+ (entry) =>
233
+ !entry ||
234
+ typeof entry !== "object" ||
235
+ !SAFE_VIEW_SORTS.has((entry as { field?: string }).field ?? "") ||
236
+ ((entry as { direction?: string }).direction !== "asc" &&
237
+ (entry as { direction?: string }).direction !== "desc"),
238
+ )
239
+ ) {
240
+ throw new Error("CRM saved view sort is unsupported.");
241
+ }
242
+ return {
243
+ id: input.id,
244
+ name: input.name,
245
+ ...(input.kind ? { kind: input.kind } : {}),
246
+ ...(input.dataProgramId ? { dataProgramId: input.dataProgramId } : {}),
247
+ ...(query ? { query } : {}),
248
+ fieldEquals: Object.fromEntries(fieldEquals) as Record<string, Primitive>,
249
+ columns,
250
+ sort: sort as Array<{ field: string; direction: "asc" | "desc" }>,
251
+ };
252
+ }
253
+
254
+ function fieldEqualsCondition(fieldName: string, value: Primitive) {
255
+ const scalarCondition =
256
+ value === null
257
+ ? and(
258
+ isNull(schema.crmRecordFields.stringValue),
259
+ isNull(schema.crmRecordFields.numberValue),
260
+ isNull(schema.crmRecordFields.booleanValue),
261
+ isNull(schema.crmRecordFields.jsonValue),
262
+ )
263
+ : typeof value === "string"
264
+ ? eq(schema.crmRecordFields.stringValue, value)
265
+ : typeof value === "number"
266
+ ? eq(schema.crmRecordFields.numberValue, value)
267
+ : eq(schema.crmRecordFields.booleanValue, value);
268
+ return exists(
269
+ getDb()
270
+ .select({ id: schema.crmRecordFields.id })
271
+ .from(schema.crmRecordFields)
272
+ .where(
273
+ and(
274
+ eq(schema.crmRecordFields.recordId, schema.crmRecords.id),
275
+ eq(schema.crmRecordFields.fieldName, fieldName),
276
+ inArray(schema.crmRecordFields.storagePolicy, [
277
+ "mirrored",
278
+ "derived-local",
279
+ "local-authoritative",
280
+ ]),
281
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
282
+ scalarCondition,
283
+ ),
284
+ ),
285
+ );
286
+ }
287
+
288
+ function orderForSavedView(view: SavedViewConfig | undefined) {
289
+ const sort = view?.sort[0];
290
+ const column =
291
+ sort?.field === "displayName"
292
+ ? schema.crmRecords.displayName
293
+ : sort?.field === "stage"
294
+ ? schema.crmRecords.stage
295
+ : sort?.field === "ownerName"
296
+ ? schema.crmRecords.ownerName
297
+ : sort?.field === "nextContactAt"
298
+ ? schema.crmRecords.nextContactAt
299
+ : sort?.field === "updatedAt"
300
+ ? schema.crmRecords.updatedAt
301
+ : schema.crmRecords.remoteUpdatedAt;
302
+ const direction = sort?.direction === "asc" ? asc : desc;
303
+ return [direction(column), desc(schema.crmRecords.id)];
304
+ }
305
+
306
+ async function defaultScopeResolver(
307
+ target: ScopeValidationTarget,
308
+ ): Promise<CrmAccessScope | null> {
309
+ if (target.provider === "native") {
310
+ return resolveNativeCrmAccessScope({
311
+ connectionId: target.connectionId,
312
+ objectType: target.objectType,
313
+ });
314
+ }
315
+ if (!isConnectedCrmProvider(target.provider) || !target.workspaceConnectionId)
316
+ return null;
317
+ const adapter = await createConnectedCrmAdapter({
318
+ provider: target.provider,
319
+ connectionId: target.workspaceConnectionId,
320
+ });
321
+ return adapter.getAccessScope(target.objectType);
322
+ }
323
+
324
+ export async function listCrmRecords(
325
+ input: {
326
+ kind?: CrmListKind;
327
+ connectionId?: string;
328
+ query?: string;
329
+ viewId?: string;
330
+ limit: number;
331
+ cursor?: string;
332
+ },
333
+ options: { resolveScope?: CrmScopeResolver } = {},
334
+ ) {
335
+ const db = getDb();
336
+ const offset = decodeCursor(input.cursor);
337
+ const limit = Math.min(input.limit, MAX_RECORD_LIMIT);
338
+ const viewRow = input.viewId
339
+ ? await db
340
+ .select({
341
+ id: schema.crmSavedViews.id,
342
+ name: schema.crmSavedViews.name,
343
+ kind: schema.crmSavedViews.kind,
344
+ filtersJson: schema.crmSavedViews.filtersJson,
345
+ columnsJson: schema.crmSavedViews.columnsJson,
346
+ sortJson: schema.crmSavedViews.sortJson,
347
+ dataProgramId: schema.crmSavedViews.dataProgramId,
348
+ })
349
+ .from(schema.crmSavedViews)
350
+ .where(
351
+ and(
352
+ eq(schema.crmSavedViews.id, input.viewId),
353
+ accessFilter(schema.crmSavedViews, schema.crmSavedViewShares),
354
+ ),
355
+ )
356
+ .limit(1)
357
+ : [];
358
+ if (input.viewId && !viewRow[0])
359
+ throw new Error("CRM saved view was not found.");
360
+ const view = viewRow[0] ? parseSavedViewConfig(viewRow[0]) : undefined;
361
+ if (input.kind && view?.kind && input.kind !== view.kind) {
362
+ throw new Error("CRM saved view kind cannot be overridden.");
363
+ }
364
+ if (input.query && view?.query && input.query !== view.query) {
365
+ throw new Error("CRM saved view query cannot be overridden.");
366
+ }
367
+ const kind = view?.kind ?? input.kind;
368
+ const query = view?.query ?? input.query;
369
+ const conditions = [
370
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
371
+ accessFilter(schema.crmConnections, schema.crmConnectionShares),
372
+ eq(schema.crmRecords.tombstone, false),
373
+ ];
374
+
375
+ if (kind) conditions.push(eq(schema.crmRecords.kind, kind));
376
+ if (input.connectionId) {
377
+ conditions.push(eq(schema.crmRecords.connectionId, input.connectionId));
378
+ }
379
+ if (query) {
380
+ conditions.push(like(schema.crmRecords.displayName, `%${query}%`));
381
+ }
382
+ for (const [fieldName, value] of Object.entries(view?.fieldEquals ?? {})) {
383
+ conditions.push(fieldEqualsCondition(fieldName, value));
384
+ }
385
+
386
+ const rows = await db
387
+ .select({
388
+ id: schema.crmRecords.id,
389
+ displayName: schema.crmRecords.displayName,
390
+ kind: schema.crmRecords.kind,
391
+ primaryEmail: schema.crmRecords.primaryEmail,
392
+ domain: schema.crmRecords.domain,
393
+ stage: schema.crmRecords.stage,
394
+ ownerName: schema.crmRecords.ownerName,
395
+ nextContactAt: schema.crmRecords.nextContactAt,
396
+ remoteUpdatedAt: schema.crmRecords.remoteUpdatedAt,
397
+ updatedAt: schema.crmRecords.updatedAt,
398
+ connectionId: schema.crmRecords.connectionId,
399
+ objectType: schema.crmRecords.objectType,
400
+ provider: schema.crmRecords.provider,
401
+ accessScopeJson: schema.crmRecords.accessScopeJson,
402
+ workspaceConnectionId: schema.crmConnections.workspaceConnectionId,
403
+ })
404
+ .from(schema.crmRecords)
405
+ .innerJoin(
406
+ schema.crmConnections,
407
+ eq(schema.crmRecords.connectionId, schema.crmConnections.id),
408
+ )
409
+ .where(and(...conditions))
410
+ .orderBy(...orderForSavedView(view))
411
+ .limit(limit + 1)
412
+ .offset(offset);
413
+ const result = page(rows, limit);
414
+ const scopeResolver = options.resolveScope ?? defaultScopeResolver;
415
+ const scopeTargets = Array.from(
416
+ new Map(
417
+ result.rows.map((row) => [
418
+ `${row.connectionId}:${row.objectType}`,
419
+ {
420
+ connectionId: row.connectionId,
421
+ workspaceConnectionId: row.workspaceConnectionId,
422
+ provider: row.provider,
423
+ objectType: row.objectType,
424
+ },
425
+ ]),
426
+ ).values(),
427
+ ).slice(0, MAX_SCOPE_VALIDATIONS);
428
+ const currentScopes = new Map(
429
+ await Promise.all(
430
+ scopeTargets.map(
431
+ async (target) =>
432
+ [
433
+ `${target.connectionId}:${target.objectType}`,
434
+ await scopeResolver(target).catch(() => null),
435
+ ] as const,
436
+ ),
437
+ ),
438
+ );
439
+ const records = result.rows
440
+ .filter((row) => {
441
+ const current = currentScopes.get(
442
+ `${row.connectionId}:${row.objectType}`,
443
+ );
444
+ return Boolean(
445
+ current &&
446
+ scopesAreCompatible(parseCrmAccessScope(row.accessScopeJson), current),
447
+ );
448
+ })
449
+ .map((row) => toRecordSummary(row, view?.columns));
450
+
451
+ return {
452
+ records,
453
+ nextCursor: result.nextCursor ? String(offset + limit) : undefined,
454
+ complete: !result.nextCursor,
455
+ ...(view
456
+ ? {
457
+ appliedView: {
458
+ id: view.id,
459
+ name: view.name,
460
+ ...(view.kind ? { kind: view.kind } : {}),
461
+ ...(view.query ? { query: view.query } : {}),
462
+ ...(view.dataProgramId
463
+ ? { dataProgramId: view.dataProgramId }
464
+ : {}),
465
+ fieldEquals: view.fieldEquals,
466
+ columns: view.columns,
467
+ sort: view.sort,
468
+ },
469
+ }
470
+ : {}),
471
+ };
472
+ }
473
+
474
+ export type CrmRecordReadContext = {
475
+ id: string;
476
+ connectionId: string;
477
+ workspaceConnectionId: string | null;
478
+ provider: string;
479
+ objectType: string;
480
+ kind: CrmListKind;
481
+ remoteId: string;
482
+ accessScopeJson: string;
483
+ ownerEmail: string;
484
+ orgId: string | null;
485
+ visibility: "private" | "org" | "public";
486
+ fieldPolicies: Array<{
487
+ fieldName: string;
488
+ storagePolicy:
489
+ | "mirrored"
490
+ | "remote-only"
491
+ | "redacted"
492
+ | "derived-local"
493
+ | "local-authoritative";
494
+ readable: boolean;
495
+ sensitive: boolean;
496
+ }>;
497
+ };
498
+
499
+ export async function getCrmRecordReadContext(
500
+ recordId: string,
501
+ ): Promise<CrmRecordReadContext | null> {
502
+ const db = getDb();
503
+ const [record] = await db
504
+ .select({
505
+ id: schema.crmRecords.id,
506
+ connectionId: schema.crmRecords.connectionId,
507
+ workspaceConnectionId: schema.crmConnections.workspaceConnectionId,
508
+ provider: schema.crmRecords.provider,
509
+ objectType: schema.crmRecords.objectType,
510
+ kind: schema.crmRecords.kind,
511
+ remoteId: schema.crmRecords.remoteId,
512
+ accessScopeJson: schema.crmRecords.accessScopeJson,
513
+ ownerEmail: schema.crmRecords.ownerEmail,
514
+ orgId: schema.crmRecords.orgId,
515
+ visibility: schema.crmRecords.visibility,
516
+ })
517
+ .from(schema.crmRecords)
518
+ .innerJoin(
519
+ schema.crmConnections,
520
+ eq(schema.crmRecords.connectionId, schema.crmConnections.id),
521
+ )
522
+ .where(
523
+ and(
524
+ eq(schema.crmRecords.id, recordId),
525
+ eq(schema.crmRecords.tombstone, false),
526
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
527
+ accessFilter(schema.crmConnections, schema.crmConnectionShares),
528
+ ),
529
+ )
530
+ .limit(1);
531
+ if (!record) return null;
532
+ const fieldPolicies = await db
533
+ .select({
534
+ fieldName: schema.crmFieldPolicies.fieldName,
535
+ storagePolicy: schema.crmFieldPolicies.storagePolicy,
536
+ readable: schema.crmFieldPolicies.readable,
537
+ sensitive: schema.crmFieldPolicies.sensitive,
538
+ })
539
+ .from(schema.crmFieldPolicies)
540
+ .where(
541
+ and(
542
+ eq(schema.crmFieldPolicies.connectionId, record.connectionId),
543
+ eq(schema.crmFieldPolicies.objectType, record.objectType),
544
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
545
+ ),
546
+ );
547
+ return {
548
+ ...record,
549
+ kind: record.kind as CrmListKind,
550
+ visibility: record.visibility as "private" | "org" | "public",
551
+ fieldPolicies: fieldPolicies as CrmRecordReadContext["fieldPolicies"],
552
+ };
553
+ }
554
+
555
+ type ReadThroughRelationshipLocalRecord = {
556
+ id: string;
557
+ remoteId: string;
558
+ objectType: string;
559
+ displayName: string;
560
+ kind: string;
561
+ primaryEmail: string | null;
562
+ domain: string | null;
563
+ accessScopeJson: string;
564
+ };
565
+
566
+ type ReadThroughRelationshipData = {
567
+ relationships: CrmRelationship[];
568
+ localRecords: ReadThroughRelationshipLocalRecord[];
569
+ summaries: Array<
570
+ RelatedRecordSummary & { localId: string; remoteId: string }
571
+ >;
572
+ };
573
+
574
+ async function readThroughRelationshipData(input: {
575
+ context: CrmRecordReadContext;
576
+ relationships: CrmRelationship[];
577
+ currentScopes: Map<string, CrmAccessScope>;
578
+ }): Promise<ReadThroughRelationshipData> {
579
+ const relationships = input.relationships
580
+ .filter(
581
+ (relationship) =>
582
+ relationship.from.connectionId === input.context.connectionId &&
583
+ relationship.from.objectType === input.context.objectType &&
584
+ relationship.from.remoteId === input.context.remoteId &&
585
+ relationship.to.connectionId === input.context.connectionId,
586
+ )
587
+ .slice(0, 100);
588
+ if (!relationships.length) {
589
+ return { relationships: [], localRecords: [], summaries: [] };
590
+ }
591
+
592
+ const db = getDb();
593
+ const remoteIds = Array.from(
594
+ new Set(relationships.map((relationship) => relationship.to.remoteId)),
595
+ );
596
+ const objectTypes = Array.from(
597
+ new Set(relationships.map((relationship) => relationship.to.objectType)),
598
+ );
599
+ const candidates = await db
600
+ .select({
601
+ id: schema.crmRecords.id,
602
+ remoteId: schema.crmRecords.remoteId,
603
+ objectType: schema.crmRecords.objectType,
604
+ displayName: schema.crmRecords.displayName,
605
+ kind: schema.crmRecords.kind,
606
+ primaryEmail: schema.crmRecords.primaryEmail,
607
+ domain: schema.crmRecords.domain,
608
+ accessScopeJson: schema.crmRecords.accessScopeJson,
609
+ })
610
+ .from(schema.crmRecords)
611
+ .where(
612
+ and(
613
+ eq(schema.crmRecords.connectionId, input.context.connectionId),
614
+ eq(schema.crmRecords.tombstone, false),
615
+ inArray(schema.crmRecords.remoteId, remoteIds),
616
+ inArray(schema.crmRecords.objectType, objectTypes),
617
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
618
+ ),
619
+ );
620
+ const localRecords = candidates.filter((record) => {
621
+ const current = input.currentScopes.get(record.objectType);
622
+ return Boolean(
623
+ current &&
624
+ scopesAreCompatible(parseCrmAccessScope(record.accessScopeJson), current),
625
+ );
626
+ });
627
+ const summaries = relatedSummaries(
628
+ input.context.remoteId,
629
+ relationships,
630
+ localRecords,
631
+ );
632
+ return { relationships, localRecords, summaries };
633
+ }
634
+
635
+ export async function getReadThroughRelationshipSummaries(input: {
636
+ context: CrmRecordReadContext;
637
+ relationships: CrmRelationship[];
638
+ currentScopes: Map<string, CrmAccessScope>;
639
+ }): Promise<RelatedRecordSummary[]> {
640
+ const { summaries } = await readThroughRelationshipData(input);
641
+ return summaries;
642
+ }
643
+
644
+ export async function getCrmRecord(
645
+ recordId: string,
646
+ readThrough?: {
647
+ displayName?: string;
648
+ fields?: Record<string, CrmValue>;
649
+ remoteRevision?: string;
650
+ remoteUpdatedAt?: string;
651
+ relatedRecords?: RelatedRecordSummary[];
652
+ accessScope?: CrmAccessScope;
653
+ },
654
+ ) {
655
+ const db = getDb();
656
+ const [record] = await db
657
+ .select({
658
+ id: schema.crmRecords.id,
659
+ provider: schema.crmRecords.provider,
660
+ displayName: schema.crmRecords.displayName,
661
+ kind: schema.crmRecords.kind,
662
+ primaryEmail: schema.crmRecords.primaryEmail,
663
+ domain: schema.crmRecords.domain,
664
+ stage: schema.crmRecords.stage,
665
+ ownerName: schema.crmRecords.ownerName,
666
+ desiredCadenceDays: schema.crmRecords.desiredCadenceDays,
667
+ nextContactAt: schema.crmRecords.nextContactAt,
668
+ remoteRevision: schema.crmRecords.remoteRevision,
669
+ remoteUpdatedAt: schema.crmRecords.remoteUpdatedAt,
670
+ updatedAt: schema.crmRecords.updatedAt,
671
+ accessScopeJson: schema.crmRecords.accessScopeJson,
672
+ })
673
+ .from(schema.crmRecords)
674
+ .where(
675
+ and(
676
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
677
+ eq(schema.crmRecords.id, recordId),
678
+ eq(schema.crmRecords.tombstone, false),
679
+ ),
680
+ )
681
+ .limit(1);
682
+
683
+ if (!record) return null;
684
+
685
+ const [fieldRows, activityRows, evidenceRows, taskRows] = await Promise.all([
686
+ db
687
+ .select({
688
+ fieldName: schema.crmRecordFields.fieldName,
689
+ valueType: schema.crmRecordFields.valueType,
690
+ stringValue: schema.crmRecordFields.stringValue,
691
+ numberValue: schema.crmRecordFields.numberValue,
692
+ booleanValue: schema.crmRecordFields.booleanValue,
693
+ jsonValue: schema.crmRecordFields.jsonValue,
694
+ accessScopeJson: schema.crmRecordFields.accessScopeJson,
695
+ })
696
+ .from(schema.crmRecordFields)
697
+ .where(
698
+ and(
699
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
700
+ eq(schema.crmRecordFields.recordId, recordId),
701
+ ),
702
+ )
703
+ .orderBy(desc(schema.crmRecordFields.updatedAt))
704
+ .limit(MAX_RECORD_LIMIT),
705
+ db
706
+ .select({
707
+ id: schema.crmInteractions.id,
708
+ title: schema.crmInteractions.title,
709
+ summary: schema.crmInteractions.summary,
710
+ occurredAt: schema.crmInteractions.occurredAt,
711
+ sourceApp: schema.crmInteractions.sourceApp,
712
+ })
713
+ .from(schema.crmInteractions)
714
+ .where(
715
+ and(
716
+ accessFilter(schema.crmInteractions, schema.crmInteractionShares),
717
+ eq(schema.crmInteractions.recordId, recordId),
718
+ ),
719
+ )
720
+ .orderBy(desc(schema.crmInteractions.occurredAt))
721
+ .limit(RECORD_DETAIL_LIMIT),
722
+ db
723
+ .select({
724
+ id: schema.crmCallEvidence.id,
725
+ artifactType: schema.crmCallEvidence.artifactType,
726
+ quote: schema.crmCallEvidence.quote,
727
+ sourceUrl: schema.crmCallEvidence.sourceUrl,
728
+ capturedAt: schema.crmCallEvidence.capturedAt,
729
+ summary: schema.crmCallEvidence.summary,
730
+ })
731
+ .from(schema.crmCallEvidence)
732
+ .where(
733
+ and(
734
+ accessFilter(schema.crmCallEvidence, schema.crmCallEvidenceShares),
735
+ eq(schema.crmCallEvidence.recordId, recordId),
736
+ ),
737
+ )
738
+ .orderBy(desc(schema.crmCallEvidence.capturedAt))
739
+ .limit(RECORD_DETAIL_LIMIT),
740
+ db
741
+ .select({
742
+ id: schema.crmTasks.id,
743
+ title: schema.crmTasks.title,
744
+ status: schema.crmTasks.status,
745
+ dueAt: schema.crmTasks.dueAt,
746
+ recordId: schema.crmTasks.recordId,
747
+ })
748
+ .from(schema.crmTasks)
749
+ .where(
750
+ and(
751
+ accessFilter(schema.crmTasks, schema.crmTaskShares),
752
+ eq(schema.crmTasks.recordId, recordId),
753
+ ),
754
+ )
755
+ .orderBy(desc(schema.crmTasks.updatedAt))
756
+ .limit(RECORD_DETAIL_LIMIT),
757
+ ]);
758
+
759
+ return {
760
+ ...toRecordSummary(record),
761
+ provider: record.provider,
762
+ ...(readThrough?.displayName
763
+ ? { displayName: readThrough.displayName }
764
+ : {}),
765
+ ...(readThrough?.remoteUpdatedAt
766
+ ? { updatedAt: readThrough.remoteUpdatedAt }
767
+ : {}),
768
+ ...(readThrough?.remoteRevision
769
+ ? { remoteRevision: readThrough.remoteRevision }
770
+ : record.remoteRevision
771
+ ? { remoteRevision: record.remoteRevision }
772
+ : {}),
773
+ cadence: record.desiredCadenceDays
774
+ ? `Every ${record.desiredCadenceDays} days`
775
+ : undefined,
776
+ fields: {
777
+ ...Object.fromEntries(
778
+ fieldRows.flatMap((field) => {
779
+ const scope =
780
+ readThrough?.accessScope ??
781
+ parseCrmAccessScope(record.accessScopeJson);
782
+ if (
783
+ !scope ||
784
+ !scopesAreCompatible(
785
+ parseCrmAccessScope(field.accessScopeJson),
786
+ scope,
787
+ )
788
+ ) {
789
+ return [];
790
+ }
791
+ const value = parseStoredValue(field);
792
+ return value === null ? [] : [[field.fieldName, value]];
793
+ }),
794
+ ),
795
+ ...(readThrough?.fields ?? {}),
796
+ },
797
+ activity: activityRows.map((activity) => ({
798
+ ...activity,
799
+ actor: activity.sourceApp ?? undefined,
800
+ })),
801
+ evidence: evidenceRows.map((evidence) => ({
802
+ id: evidence.id,
803
+ label: evidence.summary || evidence.artifactType,
804
+ quote: evidence.quote || undefined,
805
+ url: evidence.sourceUrl,
806
+ observedAt: evidence.capturedAt,
807
+ })),
808
+ tasks: taskRows,
809
+ relatedRecords: readThrough?.relatedRecords ?? [],
810
+ };
811
+ }
812
+
813
+ export async function listCrmTasks(input: {
814
+ recordId?: string;
815
+ status?: "open" | "done" | "cancelled";
816
+ limit: number;
817
+ cursor?: string;
818
+ }) {
819
+ const db = getDb();
820
+ const offset = decodeCursor(input.cursor);
821
+ const limit = Math.min(input.limit, MAX_RECORD_LIMIT);
822
+ const conditions = [accessFilter(schema.crmTasks, schema.crmTaskShares)];
823
+ if (input.recordId)
824
+ conditions.push(eq(schema.crmTasks.recordId, input.recordId));
825
+ if (input.status) conditions.push(eq(schema.crmTasks.status, input.status));
826
+
827
+ const rows = await db
828
+ .select({
829
+ id: schema.crmTasks.id,
830
+ title: schema.crmTasks.title,
831
+ status: schema.crmTasks.status,
832
+ dueAt: schema.crmTasks.dueAt,
833
+ recordId: schema.crmTasks.recordId,
834
+ assignedTo: schema.crmTasks.assignedTo,
835
+ authority: schema.crmTasks.authority,
836
+ updatedAt: schema.crmTasks.updatedAt,
837
+ })
838
+ .from(schema.crmTasks)
839
+ .where(and(...conditions))
840
+ .orderBy(desc(schema.crmTasks.dueAt), desc(schema.crmTasks.id))
841
+ .limit(limit + 1)
842
+ .offset(offset);
843
+ const result = page(rows, limit);
844
+
845
+ return {
846
+ tasks: result.rows,
847
+ nextCursor: result.nextCursor ? String(offset + limit) : undefined,
848
+ complete: !result.nextCursor,
849
+ };
850
+ }
851
+
852
+ export async function listCrmSavedViews(input: { limit: number }) {
853
+ const rows = await getDb()
854
+ .select({
855
+ id: schema.crmSavedViews.id,
856
+ name: schema.crmSavedViews.name,
857
+ description: schema.crmSavedViews.description,
858
+ kind: schema.crmSavedViews.kind,
859
+ filtersJson: schema.crmSavedViews.filtersJson,
860
+ dataProgramId: schema.crmSavedViews.dataProgramId,
861
+ pinned: schema.crmSavedViews.pinned,
862
+ updatedAt: schema.crmSavedViews.updatedAt,
863
+ })
864
+ .from(schema.crmSavedViews)
865
+ .where(accessFilter(schema.crmSavedViews, schema.crmSavedViewShares))
866
+ .orderBy(
867
+ desc(schema.crmSavedViews.pinned),
868
+ desc(schema.crmSavedViews.updatedAt),
869
+ )
870
+ .limit(Math.min(input.limit, MAX_RECORD_LIMIT));
871
+
872
+ return {
873
+ views: rows.map((view) => ({
874
+ id: view.id,
875
+ name: view.name,
876
+ description: view.description || undefined,
877
+ kind: view.kind ?? undefined,
878
+ query: view.filtersJson === "{}" ? undefined : view.filtersJson,
879
+ dataProgramId: view.dataProgramId ?? undefined,
880
+ pinned: view.pinned,
881
+ updatedAt: view.updatedAt,
882
+ })),
883
+ };
884
+ }
885
+
886
+ export async function listCrmSignals(input: {
887
+ recordId: string;
888
+ limit: number;
889
+ }) {
890
+ return getDb()
891
+ .select({
892
+ id: schema.crmSignals.id,
893
+ label: schema.crmSignals.label,
894
+ kind: schema.crmSignals.kind,
895
+ confidence: schema.crmSignals.confidence,
896
+ reviewStatus: schema.crmSignals.reviewStatus,
897
+ evidenceId: schema.crmSignals.evidenceId,
898
+ startSeconds: schema.crmSignals.startSeconds,
899
+ createdAt: schema.crmSignals.createdAt,
900
+ })
901
+ .from(schema.crmSignals)
902
+ .where(
903
+ and(
904
+ eq(schema.crmSignals.recordId, input.recordId),
905
+ accessFilter(schema.crmSignals, schema.crmSignalShares),
906
+ ),
907
+ )
908
+ .orderBy(desc(schema.crmSignals.createdAt))
909
+ .limit(Math.min(Math.max(input.limit, 1), 100));
910
+ }
911
+
912
+ /**
913
+ * The field map a stored mutation payload carries, or `present: false` when it
914
+ * carries none.
915
+ *
916
+ * Only a payload wrapping its values in `fields` holds field values.
917
+ * `before_json` and `after_json` hold revision metadata (`{ remoteRevision }`)
918
+ * or a merge summary, so reading them as a field map invented a
919
+ * `remoteRevision` "field" and — worse — made a real change render as
920
+ * "Empty → Empty". A payload that never recorded a value is not a payload that
921
+ * recorded an empty one.
922
+ */
923
+ export type CrmProposalValues =
924
+ | { present: false }
925
+ | { present: true; values: Record<string, Primitive> };
926
+
927
+ export function safeProposalValues(value: string): CrmProposalValues {
928
+ const fields = parseJsonObject(value)?.fields;
929
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
930
+ return { present: false };
931
+ }
932
+ return {
933
+ present: true,
934
+ values: Object.fromEntries(
935
+ Object.entries(fields as Record<string, unknown>)
936
+ .filter(
937
+ ([fieldName, fieldValue]) =>
938
+ fieldName.length <= 120 &&
939
+ isSafeCrmMutationFieldName(fieldName) &&
940
+ isBoundedCrmValue(fieldValue) &&
941
+ parsePrimitive(fieldValue),
942
+ )
943
+ .slice(0, 20),
944
+ ) as Record<string, Primitive>,
945
+ };
946
+ }
947
+
948
+ function proposalValueOf(
949
+ source: CrmProposalValues,
950
+ name: string,
951
+ ): { known: false } | { known: true; value: Primitive } {
952
+ if (!source.present) return { known: false };
953
+ return Object.prototype.hasOwnProperty.call(source.values, name)
954
+ ? { known: true, value: source.values[name] as Primitive }
955
+ : { known: false };
956
+ }
957
+
958
+ export async function listCrmProposals(input: {
959
+ recordId?: string;
960
+ status?:
961
+ | "pending"
962
+ | "approved"
963
+ | "applied"
964
+ | "rejected"
965
+ | "conflict"
966
+ | "failed";
967
+ limit: number;
968
+ cursor?: string;
969
+ }) {
970
+ const db = getDb();
971
+ const offset = decodeCursor(input.cursor);
972
+ const limit = Math.min(input.limit, MAX_RECORD_LIMIT);
973
+ const conditions = [
974
+ accessFilter(schema.crmMutations, schema.crmMutationShares),
975
+ ];
976
+ if (input.recordId) {
977
+ conditions.push(eq(schema.crmMutations.recordId, input.recordId));
978
+ }
979
+ if (input.status)
980
+ conditions.push(eq(schema.crmMutations.status, input.status));
981
+
982
+ const rows = await db
983
+ .select({
984
+ id: schema.crmMutations.id,
985
+ recordId: schema.crmMutations.recordId,
986
+ operation: schema.crmMutations.operation,
987
+ initiatedBy: schema.crmMutations.initiatedBy,
988
+ target: schema.crmMutations.target,
989
+ policyDecision: schema.crmMutations.policyDecision,
990
+ risk: schema.crmMutations.risk,
991
+ status: schema.crmMutations.status,
992
+ expectedRemoteRevision: schema.crmMutations.expectedRemoteRevision,
993
+ patchJson: schema.crmMutations.patchJson,
994
+ beforeJson: schema.crmMutations.beforeJson,
995
+ afterJson: schema.crmMutations.afterJson,
996
+ createdAt: schema.crmMutations.createdAt,
997
+ appliedAt: schema.crmMutations.appliedAt,
998
+ })
999
+ .from(schema.crmMutations)
1000
+ .where(and(...conditions))
1001
+ .orderBy(desc(schema.crmMutations.createdAt), desc(schema.crmMutations.id))
1002
+ .limit(limit + 1)
1003
+ .offset(offset);
1004
+ const result = page(rows, limit);
1005
+ const recordIds = result.rows
1006
+ .map((row) => row.recordId)
1007
+ .filter((recordId): recordId is string => Boolean(recordId));
1008
+ const records = recordIds.length
1009
+ ? await db
1010
+ .select({
1011
+ id: schema.crmRecords.id,
1012
+ displayName: schema.crmRecords.displayName,
1013
+ provider: schema.crmRecords.provider,
1014
+ })
1015
+ .from(schema.crmRecords)
1016
+ .where(
1017
+ and(
1018
+ inArray(schema.crmRecords.id, recordIds),
1019
+ accessFilter(schema.crmRecords, schema.crmRecordShares),
1020
+ ),
1021
+ )
1022
+ : [];
1023
+ const displayNameByRecordId = new Map(
1024
+ records.map((record) => [record.id, record.displayName]),
1025
+ );
1026
+ const providerByRecordId = new Map(
1027
+ records.map((record) => [record.id, record.provider]),
1028
+ );
1029
+
1030
+ return {
1031
+ proposals: result.rows.map((proposal) => {
1032
+ const patch = safeProposalValues(proposal.patchJson);
1033
+ const before = safeProposalValues(proposal.beforeJson);
1034
+ const after = safeProposalValues(proposal.afterJson);
1035
+ const fieldNames = Array.from(
1036
+ new Set(
1037
+ [before, after, patch].flatMap((source) =>
1038
+ source.present ? Object.keys(source.values) : [],
1039
+ ),
1040
+ ),
1041
+ ).slice(0, 20);
1042
+ return {
1043
+ id: proposal.id,
1044
+ recordId: proposal.recordId,
1045
+ ...(proposal.recordId && displayNameByRecordId.get(proposal.recordId)
1046
+ ? { recordName: displayNameByRecordId.get(proposal.recordId) }
1047
+ : {}),
1048
+ ...(proposal.recordId && providerByRecordId.get(proposal.recordId)
1049
+ ? { provider: providerByRecordId.get(proposal.recordId) }
1050
+ : {}),
1051
+ operation: proposal.operation,
1052
+ initiatedBy: proposal.initiatedBy,
1053
+ target: proposal.target,
1054
+ policyDecision: proposal.policyDecision,
1055
+ risk: proposal.risk,
1056
+ status: proposal.status,
1057
+ expectedRemoteRevision: proposal.expectedRemoteRevision,
1058
+ createdAt: proposal.createdAt,
1059
+ appliedAt: proposal.appliedAt,
1060
+ // Resolved per field name, never per payload: picking `after_json`
1061
+ // wholesale whenever it held any key dropped the change `patch_json`
1062
+ // carries, which is what rendered a real edit as "Empty → Empty".
1063
+ fields: fieldNames.map((name) => {
1064
+ const beforeValue = proposalValueOf(before, name);
1065
+ const afterValue = proposalValueOf(after, name);
1066
+ const resolved = afterValue.known
1067
+ ? afterValue
1068
+ : proposalValueOf(patch, name);
1069
+ return {
1070
+ name,
1071
+ beforeKnown: beforeValue.known,
1072
+ ...(beforeValue.known ? { before: beforeValue.value } : {}),
1073
+ ...(resolved.known ? { after: resolved.value } : {}),
1074
+ };
1075
+ }),
1076
+ };
1077
+ }),
1078
+ nextCursor: result.nextCursor ? String(offset + limit) : undefined,
1079
+ complete: !result.nextCursor,
1080
+ };
1081
+ }
1082
+
1083
+ export async function getCrmOverview() {
1084
+ const [taskResult, recordResult, proposalResult] = await Promise.all([
1085
+ listCrmTasks({ status: "open", limit: 8 }),
1086
+ listCrmRecords({ limit: 5 }),
1087
+ listCrmProposals({ status: "pending", limit: 1 }),
1088
+ ]);
1089
+
1090
+ return {
1091
+ tasks: taskResult.tasks,
1092
+ records: recordResult.records,
1093
+ focus: [
1094
+ {
1095
+ label: "Open follow-up",
1096
+ value: String(taskResult.tasks.length),
1097
+ detail: taskResult.complete
1098
+ ? "Tasks currently due for attention."
1099
+ : "Showing the first 8 tasks due for attention.",
1100
+ },
1101
+ ...(proposalResult.proposals.length
1102
+ ? [
1103
+ {
1104
+ label: "Pending proposal",
1105
+ value: "1+",
1106
+ detail: "A CRM write is waiting for review.",
1107
+ },
1108
+ ]
1109
+ : []),
1110
+ ],
1111
+ };
1112
+ }