@agent-native/core 0.123.0 → 0.123.2

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 (334) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/templates-meta.ts +12 -0
  5. package/corpus/core/src/deploy/build.ts +8 -0
  6. package/corpus/core/src/server/builder-browser.ts +76 -0
  7. package/corpus/core/src/server/core-routes-plugin.ts +15 -5
  8. package/corpus/templates/assets/app/global.css +0 -4
  9. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +195 -167
  10. package/corpus/templates/assets/changelog/2026-07-23-drafts-preview-panel-now-fills-the-full-width-with-a-large-p.md +6 -0
  11. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +1 -0
  12. package/corpus/templates/content/actions/_builder-cms-write-client.ts +55 -9
  13. package/corpus/templates/content/actions/_database-source-utils.ts +756 -182
  14. package/corpus/templates/content/actions/change-content-database-source-role.ts +21 -20
  15. package/corpus/templates/content/actions/execute-builder-source-execution.ts +49 -8
  16. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +42 -9
  17. package/corpus/templates/content/actions/prepare-builder-source-review.ts +88 -90
  18. package/corpus/templates/content/actions/preview-builder-source-review.ts +4 -3
  19. package/corpus/templates/content/actions/refresh-content-database-source.ts +36 -9
  20. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +28 -24
  21. package/corpus/templates/content/app/components/editor/BubbleToolbar.tsx +62 -12
  22. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +91 -16
  23. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +15 -1
  24. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +234 -44
  25. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +194 -16
  26. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +95 -42
  27. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +4 -2
  28. package/corpus/templates/content/app/components/editor/extensions/CodeBlockNode.tsx +10 -166
  29. package/corpus/templates/content/app/components/editor/extensions/VideoBlock.tsx +32 -8
  30. package/corpus/templates/content/app/components/editor/extensions/VideoNode.ts +14 -0
  31. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +5 -1
  32. package/corpus/templates/content/app/hooks/use-content-database.ts +59 -2
  33. package/corpus/templates/content/app/hooks/use-documents.ts +18 -26
  34. package/corpus/templates/content/app/root.tsx +1 -6
  35. package/corpus/templates/content/changelog/2026-07-24-builder-can-now-publish-approved-updates-to-entries-that-are.md +6 -0
  36. package/corpus/templates/content/changelog/2026-07-24-builder-collection-loading-no-longer-restarts-from-the-begin.md +6 -0
  37. package/corpus/templates/content/changelog/2026-07-24-builder-connections-now-complete-securely-from-netlify-deplo.md +6 -0
  38. package/corpus/templates/content/changelog/2026-07-24-builder-draft-creation-now-scopes-execution-to-the-selected-.md +6 -0
  39. package/corpus/templates/content/changelog/2026-07-24-builder-review-now-distinguishes-preparing-a-safe-dry-run-from.md +6 -0
  40. package/corpus/templates/content/changelog/2026-07-24-builder-source-imports-now-recover-from-a-transient-paginati.md +6 -0
  41. package/corpus/templates/content/changelog/2026-07-24-builder-source-refresh-now-preserves-the-provider-bound-fiel.md +6 -0
  42. package/corpus/templates/content/changelog/2026-07-24-builder-update-reviews-now-open-reliably-on-large-collection.md +6 -0
  43. package/corpus/templates/content/changelog/2026-07-24-builder-write-policy-controls-now-reflect-successful-changes.md +6 -0
  44. package/corpus/templates/content/changelog/2026-07-24-date-properties-now-save-reliably-when-selected-with-the-nat.md +6 -0
  45. package/corpus/templates/content/changelog/2026-07-24-editor-slash-search-now-keeps-multi-word-block-names-open.md +6 -0
  46. package/corpus/templates/content/changelog/2026-07-24-exact-editor-slash-commands-now-execute-on-enter-even-if-the.md +6 -0
  47. package/corpus/templates/content/changelog/2026-07-24-new-collaborative-pages-now-save-their-first-edits.md +6 -0
  48. package/corpus/templates/content/changelog/2026-07-24-new-local-builder-rows-no-longer-remain-locked-behind-a-none.md +6 -0
  49. package/corpus/templates/content/changelog/2026-07-24-opening-or-closing-a-collaborative-document-no-longer-lets-u.md +6 -0
  50. package/corpus/templates/content/changelog/2026-07-24-selected-text-now-opens-the-link-editor-with-command-k-or-co.md +6 -0
  51. package/corpus/templates/content/changelog/2026-07-24-slash-menu-block-choices-no-longer-stall-pointer-interaction.md +6 -0
  52. package/corpus/templates/content/changelog/2026-07-24-uploaded-and-embedded-media-now-stay-intact-while-collaborative-document-drafts-save.md +6 -0
  53. package/corpus/templates/content/changelog/2026-07-24-video-link-insertion-now-keeps-its-source-panel-open-while-t.md +6 -0
  54. package/corpus/templates/content/changelog/2026-07-26-builder-publishing-now-shows-safe-validation-details-instead.md +6 -0
  55. package/corpus/templates/content/changelog/2026-07-26-builder-reviews-stay-responsive-on-large-collections-and-pre.md +6 -0
  56. package/corpus/templates/content/changelog/2026-07-26-collaborative-documents-no-longer-flash-duplicated-recent-bl.md +6 -0
  57. package/corpus/templates/content/changelog/2026-07-26-corrected-builder-updates-are-now-reviewed-separately-from-f.md +6 -0
  58. package/corpus/templates/content/changelog/2026-07-26-documents-now-remain-read-only-until-their-live-collaborativ.md +6 -0
  59. package/corpus/templates/content/changelog/2026-07-26-slash-command-menus-now-stay-reachable-when-editing-near-the.md +6 -0
  60. package/corpus/templates/content/changelog/2026-07-26-the-slash-menu-now-shows-one-clear-callout-choice-instead-of.md +6 -0
  61. package/corpus/templates/content/shared/api.ts +1 -0
  62. package/corpus/templates/content/shared/builder-mdx.ts +73 -16
  63. package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
  64. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
  65. package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
  66. package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
  67. package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
  68. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
  69. package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
  70. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
  71. package/corpus/templates/crm/.env.example +5 -0
  72. package/corpus/templates/crm/AGENTS.md +82 -0
  73. package/corpus/templates/crm/CHANGELOG.md +25 -0
  74. package/corpus/templates/crm/DEVELOPING.md +66 -0
  75. package/corpus/templates/crm/README.md +122 -0
  76. package/corpus/templates/crm/_gitignore +12 -0
  77. package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
  78. package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
  79. package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
  80. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  81. package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
  82. package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
  83. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
  84. package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
  85. package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
  86. package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
  87. package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
  88. package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
  89. package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
  90. package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
  91. package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
  92. package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
  93. package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
  94. package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
  95. package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
  96. package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
  97. package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
  98. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
  99. package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
  100. package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
  101. package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
  102. package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
  103. package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
  104. package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
  105. package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
  106. package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
  107. package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
  108. package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
  109. package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
  110. package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
  111. package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
  112. package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
  113. package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
  114. package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
  115. package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
  116. package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
  117. package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
  118. package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
  119. package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
  120. package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
  121. package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
  122. package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
  123. package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
  124. package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
  125. package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
  126. package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
  127. package/corpus/templates/crm/actions/navigate.ts +54 -0
  128. package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
  129. package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
  130. package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
  131. package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
  132. package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
  133. package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
  134. package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
  135. package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
  136. package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
  137. package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
  138. package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
  139. package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
  140. package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
  141. package/corpus/templates/crm/actions/run.ts +7 -0
  142. package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
  143. package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
  144. package/corpus/templates/crm/actions/sync-crm.ts +146 -0
  145. package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
  146. package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
  147. package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
  148. package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
  149. package/corpus/templates/crm/actions/view-screen.ts +324 -0
  150. package/corpus/templates/crm/agent-native.app-skill.json +69 -0
  151. package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
  152. package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
  153. package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
  154. package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
  155. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
  156. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
  157. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
  158. package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
  159. package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
  160. package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
  161. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
  162. package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
  163. package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
  164. package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
  165. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
  166. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
  167. package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
  168. package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
  169. package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
  170. package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
  171. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
  172. package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
  173. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
  174. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
  175. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
  176. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
  177. package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
  178. package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
  179. package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
  180. package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
  181. package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
  182. package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
  183. package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
  184. package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
  185. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
  186. package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
  187. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
  188. package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
  189. package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
  190. package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
  191. package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
  192. package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
  193. package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
  194. package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
  195. package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
  196. package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
  197. package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
  198. package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
  199. package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
  200. package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
  201. package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
  202. package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
  203. package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
  204. package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
  205. package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
  206. package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
  207. package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
  208. package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
  209. package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
  210. package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
  211. package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
  212. package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
  213. package/corpus/templates/crm/app/design-system.ts +3 -0
  214. package/corpus/templates/crm/app/entry.client.tsx +19 -0
  215. package/corpus/templates/crm/app/entry.server.tsx +10 -0
  216. package/corpus/templates/crm/app/global.css +167 -0
  217. package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
  218. package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
  219. package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
  220. package/corpus/templates/crm/app/i18n/index.ts +34 -0
  221. package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
  222. package/corpus/templates/crm/app/lib/navigation.ts +17 -0
  223. package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
  224. package/corpus/templates/crm/app/lib/types.ts +172 -0
  225. package/corpus/templates/crm/app/lib/utils.ts +1 -0
  226. package/corpus/templates/crm/app/root.tsx +93 -0
  227. package/corpus/templates/crm/app/routes/_index.tsx +26 -0
  228. package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
  229. package/corpus/templates/crm/app/routes/agent.tsx +5 -0
  230. package/corpus/templates/crm/app/routes/ask.tsx +53 -0
  231. package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
  232. package/corpus/templates/crm/app/routes/lists.tsx +228 -0
  233. package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
  234. package/corpus/templates/crm/app/routes/people.tsx +41 -0
  235. package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
  236. package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
  237. package/corpus/templates/crm/app/routes/records.tsx +64 -0
  238. package/corpus/templates/crm/app/routes/settings.tsx +118 -0
  239. package/corpus/templates/crm/app/routes/setup.tsx +308 -0
  240. package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
  241. package/corpus/templates/crm/app/routes/views.tsx +683 -0
  242. package/corpus/templates/crm/app/routes.ts +19 -0
  243. package/corpus/templates/crm/app/vite-env.d.ts +6 -0
  244. 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
  245. package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
  246. package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
  247. package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
  248. package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
  249. package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
  250. 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
  251. package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
  252. package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
  253. package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
  254. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
  255. package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
  256. package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
  257. package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
  258. package/corpus/templates/crm/components.json +20 -0
  259. package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
  260. package/corpus/templates/crm/docs/features/README.md +55 -0
  261. package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
  262. package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
  263. package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
  264. package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
  265. package/corpus/templates/crm/learnings.defaults.md +5 -0
  266. package/corpus/templates/crm/netlify.toml +11 -0
  267. package/corpus/templates/crm/package.json +61 -0
  268. package/corpus/templates/crm/public/favicon.svg +1 -0
  269. package/corpus/templates/crm/public/icon-180.svg +1 -0
  270. package/corpus/templates/crm/public/manifest.json +11 -0
  271. package/corpus/templates/crm/react-router.config.ts +7 -0
  272. package/corpus/templates/crm/server/crm/adapter.ts +32 -0
  273. package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
  274. package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
  275. package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
  276. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
  277. package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
  278. package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
  279. package/corpus/templates/crm/server/crm/read-through.ts +207 -0
  280. package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
  281. package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
  282. package/corpus/templates/crm/server/db/index.ts +191 -0
  283. package/corpus/templates/crm/server/db/schema.ts +647 -0
  284. package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
  285. package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
  286. package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
  287. package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
  288. package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
  289. package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
  290. package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
  291. package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
  292. package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
  293. package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
  294. package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
  295. package/corpus/templates/crm/server/middleware/auth.ts +4 -0
  296. package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
  297. package/corpus/templates/crm/server/plugins/auth.ts +14 -0
  298. package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
  299. package/corpus/templates/crm/server/plugins/db.ts +738 -0
  300. package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
  301. package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
  302. package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
  303. package/corpus/templates/crm/shared/crm-contract.ts +417 -0
  304. package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
  305. package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
  306. package/corpus/templates/crm/ssr-entry.ts +11 -0
  307. package/corpus/templates/crm/tsconfig.json +24 -0
  308. package/corpus/templates/crm/vite.config.ts +15 -0
  309. package/corpus/templates/crm/vitest.config.ts +5 -0
  310. package/corpus/templates/design/app/i18n-data.ts +12 -12
  311. package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
  312. package/corpus/toolkit/CHANGELOG.md +6 -0
  313. package/corpus/toolkit/package.json +1 -1
  314. package/corpus/toolkit/src/editor/useCollabReconcile.ts +14 -1
  315. package/dist/cli/templates-meta.d.ts.map +1 -1
  316. package/dist/cli/templates-meta.js +12 -0
  317. package/dist/cli/templates-meta.js.map +1 -1
  318. package/dist/collab/routes.d.ts +2 -2
  319. package/dist/deploy/build.js +6 -0
  320. package/dist/deploy/build.js.map +1 -1
  321. package/dist/observability/routes.d.ts +3 -3
  322. package/dist/server/builder-browser.d.ts +13 -0
  323. package/dist/server/builder-browser.d.ts.map +1 -1
  324. package/dist/server/builder-browser.js +59 -0
  325. package/dist/server/builder-browser.js.map +1 -1
  326. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  327. package/dist/server/core-routes-plugin.js +10 -5
  328. package/dist/server/core-routes-plugin.js.map +1 -1
  329. package/dist/server/transcribe-voice.d.ts +1 -1
  330. package/package.json +2 -2
  331. package/src/cli/templates-meta.ts +12 -0
  332. package/src/deploy/build.ts +8 -0
  333. package/src/server/builder-browser.ts +76 -0
  334. package/src/server/core-routes-plugin.ts +15 -5
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Shared plumbing for the CRM list actions.
3
+ *
4
+ * Lists are Attio's workflow overlay: a list has its OWN attributes (stage,
5
+ * owner, priority) whose values live on the ENTRY, not on the record. Entries
6
+ * and entry values are local-authoritative on every backend — that is what lets
7
+ * a pipeline work over a HubSpot or Salesforce mirror without a provider write.
8
+ *
9
+ * Entry attribute values are stored in `crm_record_fields` with `entryId` set;
10
+ * `recordId` stays populated either way, so `entryId IS NULL` is the
11
+ * record-vs-entry discriminator. Every value write goes through
12
+ * `writeCrmRecordField`, which is what makes a stage change open a history row.
13
+ */
14
+
15
+ import type { ActionRunContext } from "@agent-native/core/action";
16
+ import { accessFilter } from "@agent-native/core/sharing";
17
+ import {
18
+ aliasedTable,
19
+ and,
20
+ asc,
21
+ desc,
22
+ eq,
23
+ gt,
24
+ gte,
25
+ inArray,
26
+ isNotNull,
27
+ isNull,
28
+ like,
29
+ lt,
30
+ lte,
31
+ ne,
32
+ sql,
33
+ type SQL,
34
+ } from "drizzle-orm";
35
+
36
+ import { getDb, schema } from "../server/db/index.js";
37
+ import {
38
+ applyOneCrmStatusTransition,
39
+ loadCrmStatusLifecycle,
40
+ } from "../server/lib/lifecycle.js";
41
+ import {
42
+ CrmAttributeValueError,
43
+ writeCrmRecordField,
44
+ type CrmFieldWriteDb,
45
+ type CrmWritableAttribute,
46
+ } from "../server/lib/record-fields.js";
47
+ import {
48
+ ATTRIBUTE_TYPE_SPECS,
49
+ storageColumnFor,
50
+ type CrmAttributeStorageColumn,
51
+ } from "../shared/crm-attributes.js";
52
+ import type {
53
+ CrmActorType,
54
+ CrmAttributeOption,
55
+ CrmValue,
56
+ } from "../shared/crm-contract.js";
57
+ import { crmInitiatedBy } from "./_crm-action-utils.js";
58
+
59
+ type CrmDb = ReturnType<typeof getDb>;
60
+
61
+ export const MAX_LIST_LIMIT = 100;
62
+ export const MAX_LIST_ENTRY_LIMIT = 100;
63
+ export const MAX_LIST_ENTRY_FILTERS = 6;
64
+ export const MAX_LIST_ENTRY_SORTS = 2;
65
+ export const MAX_LIST_ATTRIBUTES = 100;
66
+
67
+ /**
68
+ * A list problem the caller must fix before retrying. Extends the attribute
69
+ * writer's error so every "your input is wrong" failure out of the CRM write
70
+ * path carries the same 422 contract.
71
+ */
72
+ export class CrmListError extends CrmAttributeValueError {
73
+ constructor(code: string, message: string) {
74
+ super(code, message);
75
+ this.name = "CrmListError";
76
+ }
77
+ }
78
+
79
+ export interface CrmOwnership {
80
+ ownerEmail: string;
81
+ orgId: string | null;
82
+ visibility: "private" | "org" | "public";
83
+ }
84
+
85
+ /** Who is making the write, in `crm_record_fields.actor_type` terms. */
86
+ export function crmActorFrom(ctx?: ActionRunContext): {
87
+ type: CrmActorType;
88
+ id: string | null;
89
+ } {
90
+ const initiator = crmInitiatedBy(ctx);
91
+ const type: CrmActorType =
92
+ initiator === "human"
93
+ ? "user"
94
+ : initiator === "agent"
95
+ ? "agent"
96
+ : "automation";
97
+ return { type, id: ctx?.userEmail ?? null };
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Lists
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export function crmApiSlug(value: string): string {
105
+ const slug = value
106
+ .trim()
107
+ .toLowerCase()
108
+ .replace(/[^a-z0-9]+/g, "_")
109
+ .replace(/^_+|_+$/g, "")
110
+ .slice(0, 60);
111
+ return slug || "list";
112
+ }
113
+
114
+ /**
115
+ * A slug that is free among the lists this caller can see. Uniqueness is
116
+ * scoped, not global — the id is the key, `api_slug` is the stable human handle
117
+ * and is immutable once assigned.
118
+ */
119
+ export async function uniqueCrmListSlug(
120
+ db: CrmDb,
121
+ connectionId: string,
122
+ base: string,
123
+ ): Promise<string> {
124
+ const rows = await db
125
+ .select({ apiSlug: schema.crmLists.apiSlug })
126
+ .from(schema.crmLists)
127
+ .where(
128
+ and(
129
+ eq(schema.crmLists.connectionId, connectionId),
130
+ like(schema.crmLists.apiSlug, `${base}%`),
131
+ accessFilter(schema.crmLists, schema.crmListShares),
132
+ ),
133
+ )
134
+ .limit(MAX_LIST_LIMIT);
135
+ const taken = new Set(rows.map((row) => row.apiSlug));
136
+ if (!taken.has(base)) return base;
137
+ for (let suffix = 2; suffix <= MAX_LIST_LIMIT + 1; suffix += 1) {
138
+ const candidate = `${base}_${suffix}`;
139
+ if (!taken.has(candidate)) return candidate;
140
+ }
141
+ throw new CrmListError(
142
+ "crm-list-slug-exhausted",
143
+ `Could not derive a free api_slug from "${base}". Rename the list.`,
144
+ );
145
+ }
146
+
147
+ export type CrmListRow = typeof schema.crmLists.$inferSelect;
148
+
149
+ export async function requireCrmList(
150
+ db: CrmDb,
151
+ listId: string,
152
+ minRole: "viewer" | "editor",
153
+ ): Promise<CrmListRow> {
154
+ const [list] = await db
155
+ .select()
156
+ .from(schema.crmLists)
157
+ .where(
158
+ and(
159
+ eq(schema.crmLists.id, listId),
160
+ accessFilter(schema.crmLists, schema.crmListShares, undefined, minRole),
161
+ ),
162
+ )
163
+ .limit(1);
164
+ if (!list) {
165
+ throw new CrmListError(
166
+ "crm-list-not-found",
167
+ `CRM list "${listId}" was not found or you do not have ${minRole} access to it.`,
168
+ );
169
+ }
170
+ return list;
171
+ }
172
+
173
+ export type CrmListEntryRow = typeof schema.crmListEntries.$inferSelect;
174
+
175
+ export async function requireCrmListEntry(
176
+ db: CrmDb,
177
+ entryId: string,
178
+ minRole: "viewer" | "editor",
179
+ ): Promise<CrmListEntryRow> {
180
+ const [entry] = await db
181
+ .select()
182
+ .from(schema.crmListEntries)
183
+ .where(
184
+ and(
185
+ eq(schema.crmListEntries.id, entryId),
186
+ accessFilter(
187
+ schema.crmListEntries,
188
+ schema.crmListEntryShares,
189
+ undefined,
190
+ minRole,
191
+ ),
192
+ ),
193
+ )
194
+ .limit(1);
195
+ if (!entry) {
196
+ throw new CrmListError(
197
+ "crm-list-entry-not-found",
198
+ `CRM list entry "${entryId}" was not found or you do not have ${minRole} access to it.`,
199
+ );
200
+ }
201
+ return entry;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // List attributes
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export interface CrmListAttribute extends CrmWritableAttribute {
209
+ label: string;
210
+ description: string | null;
211
+ position: number;
212
+ required: boolean;
213
+ options: CrmAttributeOption[];
214
+ }
215
+
216
+ /**
217
+ * The list's own attributes. `target = "list"` with `target_id = listId`;
218
+ * `object_type` mirrors `target_id` so the legacy
219
+ * `(connection_id, object_type, field_name)` unique index keeps guarding one
220
+ * attribute per list.
221
+ */
222
+ export async function loadCrmListAttributes(
223
+ db: CrmDb,
224
+ listId: string,
225
+ ): Promise<CrmListAttribute[]> {
226
+ const rows = await db
227
+ .select({
228
+ id: schema.crmFieldPolicies.id,
229
+ apiSlug: schema.crmFieldPolicies.apiSlug,
230
+ fieldName: schema.crmFieldPolicies.fieldName,
231
+ label: schema.crmFieldPolicies.label,
232
+ description: schema.crmFieldPolicies.description,
233
+ attributeType: schema.crmFieldPolicies.attributeType,
234
+ multi: schema.crmFieldPolicies.multi,
235
+ historyTracked: schema.crmFieldPolicies.historyTracked,
236
+ valueType: schema.crmFieldPolicies.valueType,
237
+ storagePolicy: schema.crmFieldPolicies.storagePolicy,
238
+ position: schema.crmFieldPolicies.position,
239
+ required: schema.crmFieldPolicies.required,
240
+ })
241
+ .from(schema.crmFieldPolicies)
242
+ .where(
243
+ and(
244
+ eq(schema.crmFieldPolicies.target, "list"),
245
+ eq(schema.crmFieldPolicies.targetId, listId),
246
+ eq(schema.crmFieldPolicies.archived, false),
247
+ accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
248
+ ),
249
+ )
250
+ .limit(MAX_LIST_ATTRIBUTES);
251
+
252
+ const attributeIds = rows.map((row) => row.id);
253
+ const optionRows = attributeIds.length
254
+ ? await db
255
+ .select({
256
+ id: schema.crmAttributeOptions.id,
257
+ attributeId: schema.crmAttributeOptions.attributeId,
258
+ value: schema.crmAttributeOptions.value,
259
+ title: schema.crmAttributeOptions.title,
260
+ color: schema.crmAttributeOptions.color,
261
+ position: schema.crmAttributeOptions.position,
262
+ archived: schema.crmAttributeOptions.archived,
263
+ targetDays: schema.crmAttributeOptions.targetDays,
264
+ celebrate: schema.crmAttributeOptions.celebrate,
265
+ })
266
+ .from(schema.crmAttributeOptions)
267
+ .where(
268
+ and(
269
+ inArray(schema.crmAttributeOptions.attributeId, attributeIds),
270
+ accessFilter(
271
+ schema.crmAttributeOptions,
272
+ schema.crmAttributeOptionShares,
273
+ ),
274
+ ),
275
+ )
276
+ : [];
277
+
278
+ const optionsByAttribute = new Map<string, CrmAttributeOption[]>();
279
+ for (const option of optionRows) {
280
+ const bucket = optionsByAttribute.get(option.attributeId) ?? [];
281
+ bucket.push({
282
+ id: option.id,
283
+ value: option.value,
284
+ title: option.title,
285
+ ...(option.color === null ? {} : { color: option.color }),
286
+ position: option.position,
287
+ archived: option.archived,
288
+ targetDays: option.targetDays,
289
+ celebrate: option.celebrate,
290
+ });
291
+ optionsByAttribute.set(option.attributeId, bucket);
292
+ }
293
+
294
+ return rows
295
+ .map((row) => {
296
+ // A list attribute is local-authoritative by construction. A provider
297
+ // storage policy here means the row was mis-seeded, not that the value is
298
+ // merely unwritable — say so instead of silently degrading the write.
299
+ if (
300
+ row.storagePolicy !== "local-authoritative" &&
301
+ row.storagePolicy !== "derived-local" &&
302
+ row.storagePolicy !== "mirrored"
303
+ ) {
304
+ throw new CrmListError(
305
+ "crm-list-attribute-not-writable",
306
+ `List attribute "${row.apiSlug ?? row.fieldName}" has storage policy "${row.storagePolicy}". List attributes are local-authoritative.`,
307
+ );
308
+ }
309
+ return {
310
+ id: row.id,
311
+ apiSlug: row.apiSlug ?? row.fieldName,
312
+ attributeType: row.attributeType,
313
+ multi: row.multi,
314
+ historyTracked: row.historyTracked,
315
+ valueType: row.valueType,
316
+ storagePolicy: row.storagePolicy,
317
+ fieldPolicyId: row.id,
318
+ label: row.label,
319
+ description: row.description,
320
+ position: row.position,
321
+ required: row.required,
322
+ options: (optionsByAttribute.get(row.id) ?? []).sort(
323
+ (a, b) => a.position - b.position,
324
+ ),
325
+ } satisfies CrmListAttribute;
326
+ })
327
+ .sort(
328
+ (a, b) => a.position - b.position || a.apiSlug.localeCompare(b.apiSlug),
329
+ );
330
+ }
331
+
332
+ export function indexAttributes(
333
+ attributes: CrmListAttribute[],
334
+ ): Map<string, CrmListAttribute> {
335
+ return new Map(attributes.map((attribute) => [attribute.apiSlug, attribute]));
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Entry attribute values
340
+ // ---------------------------------------------------------------------------
341
+
342
+ interface StoredValueRow {
343
+ stringValue: string | null;
344
+ numberValue: number | null;
345
+ booleanValue: boolean | null;
346
+ jsonValue: string | null;
347
+ }
348
+
349
+ /**
350
+ * Decode a stored value using the attribute's declared storage column rather
351
+ * than guessing from which column is non-null — a `false` checkbox and an empty
352
+ * text field are otherwise indistinguishable.
353
+ */
354
+ export function decodeCrmAttributeValue(
355
+ attribute: CrmListAttribute,
356
+ row: StoredValueRow,
357
+ ): CrmValue {
358
+ const column = storageColumnFor(attribute.attributeType, attribute.multi);
359
+ if (column === "numberValue") return row.numberValue;
360
+ if (column === "booleanValue") return row.booleanValue;
361
+ if (column === "jsonValue") {
362
+ if (row.jsonValue === null) return null;
363
+ try {
364
+ return JSON.parse(row.jsonValue) as CrmValue;
365
+ } catch {
366
+ throw new CrmListError(
367
+ "crm-attribute-value-unreadable",
368
+ `Stored value for "${attribute.apiSlug}" is not readable JSON. It was written outside the attribute writer.`,
369
+ );
370
+ }
371
+ }
372
+ return row.stringValue;
373
+ }
374
+
375
+ export interface CrmEntryValues {
376
+ values: Record<string, CrmValue>;
377
+ /** `activeFrom` of the current value — what a stage SLA is measured from. */
378
+ valuesSince: Record<string, string>;
379
+ }
380
+
381
+ export async function loadCrmEntryValues(
382
+ db: CrmDb,
383
+ entryIds: string[],
384
+ attributes: Map<string, CrmListAttribute>,
385
+ ): Promise<Map<string, CrmEntryValues>> {
386
+ const byEntry = new Map<string, CrmEntryValues>();
387
+ for (const entryId of entryIds) {
388
+ byEntry.set(entryId, { values: {}, valuesSince: {} });
389
+ }
390
+ if (entryIds.length === 0) return byEntry;
391
+
392
+ const rows = await db
393
+ .select({
394
+ entryId: schema.crmRecordFields.entryId,
395
+ fieldName: schema.crmRecordFields.fieldName,
396
+ stringValue: schema.crmRecordFields.stringValue,
397
+ numberValue: schema.crmRecordFields.numberValue,
398
+ booleanValue: schema.crmRecordFields.booleanValue,
399
+ jsonValue: schema.crmRecordFields.jsonValue,
400
+ activeFrom: schema.crmRecordFields.activeFrom,
401
+ })
402
+ .from(schema.crmRecordFields)
403
+ .where(
404
+ and(
405
+ inArray(schema.crmRecordFields.entryId, entryIds),
406
+ isNull(schema.crmRecordFields.activeUntil),
407
+ accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
408
+ ),
409
+ );
410
+
411
+ for (const row of rows) {
412
+ if (!row.entryId) continue;
413
+ const attribute = attributes.get(row.fieldName);
414
+ const bucket = byEntry.get(row.entryId);
415
+ if (!attribute || !bucket) continue;
416
+ bucket.values[attribute.apiSlug] = decodeCrmAttributeValue(attribute, row);
417
+ bucket.valuesSince[attribute.apiSlug] = row.activeFrom;
418
+ }
419
+ return byEntry;
420
+ }
421
+
422
+ export interface CrmEntryValueWrite {
423
+ attribute: string;
424
+ changed: boolean;
425
+ mode?: "insert" | "close-and-insert" | "update-in-place";
426
+ }
427
+
428
+ /**
429
+ * Write entry attribute values through the bitemporal writer. A stage move is
430
+ * exactly this call — the row it closes and the row it opens ARE the stage
431
+ * history the board reports time-in-stage from.
432
+ *
433
+ * A `status` value is routed through the lifecycle rather than written
434
+ * directly: the enterable set comes from the attribute's own options, and the
435
+ * move is claimed against the value the decision was made from, so a stage
436
+ * somebody else moved in between is refused instead of clobbered. Clearing a
437
+ * status (`null`) is not a transition into anything and stays an ordinary
438
+ * write — the way out of a retired stage must not itself be blocked.
439
+ */
440
+ export async function writeCrmListEntryValues(input: {
441
+ db: CrmFieldWriteDb;
442
+ recordId: string;
443
+ entryId: string;
444
+ attributes: Map<string, CrmListAttribute>;
445
+ values: Record<string, unknown>;
446
+ actor: { type: CrmActorType; id?: string | null };
447
+ ownership: CrmOwnership;
448
+ now: string;
449
+ }): Promise<CrmEntryValueWrite[]> {
450
+ const writes: CrmEntryValueWrite[] = [];
451
+ for (const [slug, value] of Object.entries(input.values)) {
452
+ const attribute = input.attributes.get(slug);
453
+ if (!attribute) {
454
+ const known = [...input.attributes.keys()];
455
+ throw new CrmListError(
456
+ "crm-list-attribute-unknown",
457
+ `"${slug}" is not an attribute of this list. Known attributes: ${
458
+ known.length ? known.join(", ") : "(none)"
459
+ }.`,
460
+ );
461
+ }
462
+ if (attribute.attributeType === "status" && typeof value === "string") {
463
+ const moved = await applyOneCrmStatusTransition({
464
+ db: input.db,
465
+ lifecycle: await loadCrmStatusLifecycle(input.db, attribute.id),
466
+ target: { recordId: input.recordId, entryId: input.entryId },
467
+ to: value,
468
+ actor: input.actor,
469
+ ownership: input.ownership,
470
+ now: input.now,
471
+ });
472
+ writes.push({
473
+ attribute: slug,
474
+ changed: moved.changed,
475
+ ...(moved.mode ? { mode: moved.mode } : {}),
476
+ });
477
+ continue;
478
+ }
479
+
480
+ const result = await writeCrmRecordField({
481
+ db: input.db,
482
+ target: { recordId: input.recordId, entryId: input.entryId },
483
+ attribute,
484
+ value: value as CrmValue,
485
+ actor: input.actor,
486
+ ownership: input.ownership,
487
+ now: input.now,
488
+ });
489
+ writes.push({
490
+ attribute: slug,
491
+ changed: result.changed,
492
+ ...(result.changed ? { mode: result.mode } : {}),
493
+ });
494
+ }
495
+ return writes;
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Server-side filter / sort over entries
500
+ //
501
+ // Filters and sorts are pushed into SQL, never applied after the page is cut —
502
+ // a client-side filter over one page silently returns the wrong rows.
503
+ // ---------------------------------------------------------------------------
504
+
505
+ export const CRM_ENTRY_FILTER_OPERATORS = [
506
+ "eq",
507
+ "neq",
508
+ "contains",
509
+ "gt",
510
+ "gte",
511
+ "lt",
512
+ "lte",
513
+ "in",
514
+ "is-empty",
515
+ "is-not-empty",
516
+ ] as const;
517
+
518
+ export type CrmEntryFilterOperator =
519
+ (typeof CRM_ENTRY_FILTER_OPERATORS)[number];
520
+
521
+ export type CrmEntryFilterValue =
522
+ | string
523
+ | number
524
+ | boolean
525
+ | null
526
+ | Array<string | number | boolean>;
527
+
528
+ export interface CrmEntryFilter {
529
+ attribute: string;
530
+ operator: CrmEntryFilterOperator;
531
+ value?: CrmEntryFilterValue;
532
+ }
533
+
534
+ export interface CrmEntrySort {
535
+ attribute: string;
536
+ direction: "asc" | "desc";
537
+ }
538
+
539
+ type FieldKind = "text" | "number" | "boolean" | "json";
540
+
541
+ interface ResolvedField {
542
+ /** Drizzle column reference, possibly on a joined `crm_record_fields` alias. */
543
+ column: any;
544
+ kind: FieldKind;
545
+ }
546
+
547
+ const RECORD_FIELDS: Record<string, () => ResolvedField> = {
548
+ "record.displayName": () => ({
549
+ column: schema.crmRecords.displayName,
550
+ kind: "text",
551
+ }),
552
+ "record.kind": () => ({ column: schema.crmRecords.kind, kind: "text" }),
553
+ "record.stage": () => ({ column: schema.crmRecords.stage, kind: "text" }),
554
+ "record.ownerName": () => ({
555
+ column: schema.crmRecords.ownerName,
556
+ kind: "text",
557
+ }),
558
+ "record.domain": () => ({ column: schema.crmRecords.domain, kind: "text" }),
559
+ "record.primaryEmail": () => ({
560
+ column: schema.crmRecords.primaryEmail,
561
+ kind: "text",
562
+ }),
563
+ "record.amount": () => ({ column: schema.crmRecords.amount, kind: "number" }),
564
+ "record.closeDate": () => ({
565
+ column: schema.crmRecords.closeDate,
566
+ kind: "text",
567
+ }),
568
+ "record.updatedAt": () => ({
569
+ column: schema.crmRecords.updatedAt,
570
+ kind: "text",
571
+ }),
572
+ "entry.position": () => ({
573
+ column: schema.crmListEntries.position,
574
+ kind: "number",
575
+ }),
576
+ "entry.createdAt": () => ({
577
+ column: schema.crmListEntries.createdAt,
578
+ kind: "text",
579
+ }),
580
+ };
581
+
582
+ export const CRM_ENTRY_BUILTIN_FIELDS = Object.keys(RECORD_FIELDS);
583
+
584
+ const STORAGE_KIND: Record<CrmAttributeStorageColumn, FieldKind> = {
585
+ stringValue: "text",
586
+ numberValue: "number",
587
+ booleanValue: "boolean",
588
+ jsonValue: "json",
589
+ };
590
+
591
+ export interface EntryJoin {
592
+ table: any;
593
+ on: SQL;
594
+ }
595
+
596
+ /**
597
+ * Resolves a filter/sort field name to a column, adding one LEFT JOIN per
598
+ * referenced list attribute. Bounded by the filter/sort caps, so the join count
599
+ * is bounded too.
600
+ */
601
+ export class CrmEntryFieldResolver {
602
+ readonly joins: EntryJoin[] = [];
603
+ private readonly cache = new Map<string, ResolvedField>();
604
+
605
+ constructor(private readonly attributes: Map<string, CrmListAttribute>) {}
606
+
607
+ resolve(name: string): ResolvedField {
608
+ const cached = this.cache.get(name);
609
+ if (cached) return cached;
610
+
611
+ const builtin = RECORD_FIELDS[name];
612
+ if (builtin) {
613
+ const field = builtin();
614
+ this.cache.set(name, field);
615
+ return field;
616
+ }
617
+
618
+ const attribute = this.attributes.get(name);
619
+ if (!attribute) {
620
+ throw new CrmListError(
621
+ "crm-list-field-unknown",
622
+ `"${name}" is neither a list attribute nor one of: ${CRM_ENTRY_BUILTIN_FIELDS.join(", ")}.`,
623
+ );
624
+ }
625
+
626
+ const alias = aliasedTable(
627
+ schema.crmRecordFields,
628
+ `crm_entry_field_${this.joins.length}`,
629
+ );
630
+ this.joins.push({
631
+ table: alias,
632
+ on: and(
633
+ eq(alias.entryId, schema.crmListEntries.id),
634
+ eq(alias.fieldName, attribute.apiSlug),
635
+ isNull(alias.activeUntil),
636
+ accessFilter(alias, schema.crmRecordFieldShares),
637
+ ) as SQL,
638
+ });
639
+
640
+ const field: ResolvedField = {
641
+ column: alias[storageColumnFor(attribute.attributeType, attribute.multi)],
642
+ kind: STORAGE_KIND[
643
+ storageColumnFor(attribute.attributeType, attribute.multi)
644
+ ],
645
+ };
646
+ this.cache.set(name, field);
647
+ return field;
648
+ }
649
+ }
650
+
651
+ function coerceScalar(
652
+ name: string,
653
+ kind: FieldKind,
654
+ value: CrmEntryFilterValue | undefined,
655
+ ): string | number | boolean {
656
+ if (kind === "number") {
657
+ if (typeof value !== "number" || !Number.isFinite(value)) {
658
+ throw new CrmListError(
659
+ "crm-list-filter-value",
660
+ `Filter on "${name}" expects a finite number.`,
661
+ );
662
+ }
663
+ return value;
664
+ }
665
+ if (kind === "boolean") {
666
+ if (typeof value !== "boolean") {
667
+ throw new CrmListError(
668
+ "crm-list-filter-value",
669
+ `Filter on "${name}" expects a boolean.`,
670
+ );
671
+ }
672
+ return value;
673
+ }
674
+ if (typeof value !== "string") {
675
+ throw new CrmListError(
676
+ "crm-list-filter-value",
677
+ `Filter on "${name}" expects a string.`,
678
+ );
679
+ }
680
+ return value;
681
+ }
682
+
683
+ export function buildEntryFilter(
684
+ resolver: CrmEntryFieldResolver,
685
+ filter: CrmEntryFilter,
686
+ ): SQL {
687
+ const field = resolver.resolve(filter.attribute);
688
+ const { column, kind } = field;
689
+
690
+ if (filter.operator === "is-empty") return isNull(column);
691
+ if (filter.operator === "is-not-empty") return isNotNull(column);
692
+
693
+ if (kind === "json" && filter.operator !== "contains") {
694
+ throw new CrmListError(
695
+ "crm-list-filter-operator",
696
+ `"${filter.attribute}" is stored as a structured value; only contains, is-empty, and is-not-empty apply.`,
697
+ );
698
+ }
699
+
700
+ if (filter.operator === "contains") {
701
+ if (typeof filter.value !== "string") {
702
+ throw new CrmListError(
703
+ "crm-list-filter-value",
704
+ `Filter "contains" on "${filter.attribute}" expects a string.`,
705
+ );
706
+ }
707
+ // `lower(...)` on both sides: SQLite LIKE folds ASCII case, Postgres LIKE
708
+ // does not, and a filter that silently differs by dialect is a bug.
709
+ // A structured value is matched on its quoted JSON token so `won` cannot
710
+ // match `unwon` inside a multi-value array.
711
+ const needle =
712
+ kind === "json"
713
+ ? `%"${filter.value.toLowerCase()}"%`
714
+ : `%${filter.value.toLowerCase()}%`;
715
+ return like(sql`lower(${column})`, needle);
716
+ }
717
+
718
+ if (filter.operator === "in") {
719
+ if (!Array.isArray(filter.value) || filter.value.length === 0) {
720
+ throw new CrmListError(
721
+ "crm-list-filter-value",
722
+ `Filter "in" on "${filter.attribute}" expects a non-empty array.`,
723
+ );
724
+ }
725
+ return inArray(
726
+ column,
727
+ filter.value.map((entry) => coerceScalar(filter.attribute, kind, entry)),
728
+ );
729
+ }
730
+
731
+ const value = coerceScalar(filter.attribute, kind, filter.value);
732
+ if (filter.operator === "eq") return eq(column, value);
733
+ if (filter.operator === "neq") return ne(column, value);
734
+ if (filter.operator === "gt") return gt(column, value);
735
+ if (filter.operator === "gte") return gte(column, value);
736
+ if (filter.operator === "lt") return lt(column, value);
737
+ return lte(column, value);
738
+ }
739
+
740
+ /**
741
+ * ORDER BY terms for one sort key. The leading `(col is null)` term places
742
+ * empty values last in both dialects — SQLite sorts NULLs first and Postgres
743
+ * sorts them last, and pagination over a page boundary must not depend on that.
744
+ */
745
+ export function buildEntryOrder(
746
+ resolver: CrmEntryFieldResolver,
747
+ sort: CrmEntrySort,
748
+ ): SQL[] {
749
+ const { column } = resolver.resolve(sort.attribute);
750
+ return [
751
+ sql`(${column} is null)`,
752
+ sort.direction === "desc" ? desc(column) : asc(column),
753
+ ];
754
+ }
755
+
756
+ export function decodeCrmCursor(cursor: string | undefined): number {
757
+ if (!cursor) return 0;
758
+ const value = Number.parseInt(cursor, 10);
759
+ return Number.isSafeInteger(value) && value >= 0 ? value : 0;
760
+ }
761
+
762
+ export function attributeSummary(attribute: CrmListAttribute) {
763
+ return {
764
+ id: attribute.id,
765
+ apiSlug: attribute.apiSlug,
766
+ label: attribute.label,
767
+ description: attribute.description,
768
+ attributeType: attribute.attributeType,
769
+ multi: attribute.multi,
770
+ required: attribute.required,
771
+ position: attribute.position,
772
+ usesOptions: ATTRIBUTE_TYPE_SPECS[attribute.attributeType].usesOptions,
773
+ options: attribute.options,
774
+ };
775
+ }