@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/templates-meta.ts +12 -0
- package/corpus/templates/crm/.agents/skills/agent-native-docs/SKILL.md +115 -0
- package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +177 -0
- package/corpus/templates/crm/.agents/skills/crm/SKILL.md +205 -0
- package/corpus/templates/crm/.agents/skills/customizing-agent-native/SKILL.md +220 -0
- package/corpus/templates/crm/.agents/skills/feature-flags/SKILL.md +169 -0
- package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +228 -0
- package/corpus/templates/crm/.agents/skills/storing-data/SKILL.md +178 -0
- package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +122 -0
- package/corpus/templates/crm/.env.example +5 -0
- package/corpus/templates/crm/AGENTS.md +82 -0
- package/corpus/templates/crm/CHANGELOG.md +25 -0
- package/corpus/templates/crm/DEVELOPING.md +66 -0
- package/corpus/templates/crm/README.md +122 -0
- package/corpus/templates/crm/_gitignore +12 -0
- package/corpus/templates/crm/actions/_crm-action-utils.ts +98 -0
- package/corpus/templates/crm/actions/_crm-attribute-utils.ts +219 -0
- package/corpus/templates/crm/actions/_crm-dashboard.ts +69 -0
- package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
- package/corpus/templates/crm/actions/_crm-list-utils.ts +775 -0
- package/corpus/templates/crm/actions/_crm-signal-utils.ts +79 -0
- package/corpus/templates/crm/actions/add-crm-record-to-list.ts +164 -0
- package/corpus/templates/crm/actions/apply-crm-proposals.ts +291 -0
- package/corpus/templates/crm/actions/archive-crm-attribute.ts +69 -0
- package/corpus/templates/crm/actions/attach-call-evidence.ts +170 -0
- package/corpus/templates/crm/actions/configure-crm-connection.ts +126 -0
- package/corpus/templates/crm/actions/configure-native-crm.ts +44 -0
- package/corpus/templates/crm/actions/create-crm-attribute.ts +350 -0
- package/corpus/templates/crm/actions/create-crm-list.ts +194 -0
- package/corpus/templates/crm/actions/create-crm-record.ts +148 -0
- package/corpus/templates/crm/actions/create-crm-signal-tracker.ts +58 -0
- package/corpus/templates/crm/actions/delete-crm-saved-view.ts +68 -0
- package/corpus/templates/crm/actions/delete-staged-dataset.ts +14 -0
- package/corpus/templates/crm/actions/estimate-crm-enrichment.ts +229 -0
- package/corpus/templates/crm/actions/find-crm-duplicates.ts +87 -0
- package/corpus/templates/crm/actions/get-crm-automation-recipe.ts +36 -0
- package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +35 -0
- package/corpus/templates/crm/actions/get-crm-dashboard.ts +14 -0
- package/corpus/templates/crm/actions/get-crm-overview.ts +13 -0
- package/corpus/templates/crm/actions/get-crm-pipeline-data.ts +61 -0
- package/corpus/templates/crm/actions/get-crm-record-page.ts +287 -0
- package/corpus/templates/crm/actions/get-crm-record.ts +75 -0
- package/corpus/templates/crm/actions/get-crm-workspace.ts +364 -0
- package/corpus/templates/crm/actions/install-crm-pipeline-dashboard.ts +81 -0
- package/corpus/templates/crm/actions/list-crm-attributes.ts +71 -0
- package/corpus/templates/crm/actions/list-crm-connections.ts +68 -0
- package/corpus/templates/crm/actions/list-crm-dashboard-revisions.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-dashboards.ts +15 -0
- package/corpus/templates/crm/actions/list-crm-enrichment-slots.ts +28 -0
- package/corpus/templates/crm/actions/list-crm-list-entries.ts +192 -0
- package/corpus/templates/crm/actions/list-crm-lists.ts +80 -0
- package/corpus/templates/crm/actions/list-crm-proposals.ts +55 -0
- package/corpus/templates/crm/actions/list-crm-record-field-history.ts +201 -0
- package/corpus/templates/crm/actions/list-crm-record-values.ts +200 -0
- package/corpus/templates/crm/actions/list-crm-records.ts +103 -0
- package/corpus/templates/crm/actions/list-crm-saved-views.ts +66 -0
- package/corpus/templates/crm/actions/list-crm-signal-hits.ts +43 -0
- package/corpus/templates/crm/actions/list-crm-signal-trackers.ts +32 -0
- package/corpus/templates/crm/actions/list-crm-tasks.ts +22 -0
- package/corpus/templates/crm/actions/list-staged-datasets.ts +14 -0
- package/corpus/templates/crm/actions/list-workspace-connections.ts +47 -0
- package/corpus/templates/crm/actions/manage-crm-attribute-option.ts +272 -0
- package/corpus/templates/crm/actions/manage-crm-signal-tracker.ts +87 -0
- package/corpus/templates/crm/actions/manage-crm-task.ts +132 -0
- package/corpus/templates/crm/actions/merge-crm-records.ts +691 -0
- package/corpus/templates/crm/actions/navigate.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-catalog.ts +22 -0
- package/corpus/templates/crm/actions/provider-api-docs.ts +54 -0
- package/corpus/templates/crm/actions/provider-api-request.ts +92 -0
- package/corpus/templates/crm/actions/query-staged-dataset.ts +44 -0
- package/corpus/templates/crm/actions/record-crm-call-insight.ts +160 -0
- package/corpus/templates/crm/actions/record-crm-smart-signal.ts +150 -0
- package/corpus/templates/crm/actions/remove-crm-list-entry.ts +79 -0
- package/corpus/templates/crm/actions/restore-crm-dashboard-revision.ts +23 -0
- package/corpus/templates/crm/actions/review-crm-signal.ts +31 -0
- package/corpus/templates/crm/actions/run-crm-attribute-fill.ts +443 -0
- package/corpus/templates/crm/actions/run-crm-enrichment.ts +647 -0
- package/corpus/templates/crm/actions/run-crm-saved-view-program.ts +103 -0
- package/corpus/templates/crm/actions/run-crm-signal-trackers.ts +265 -0
- package/corpus/templates/crm/actions/run.ts +7 -0
- package/corpus/templates/crm/actions/save-crm-dashboard.ts +51 -0
- package/corpus/templates/crm/actions/save-crm-saved-view.ts +296 -0
- package/corpus/templates/crm/actions/sync-crm.ts +146 -0
- package/corpus/templates/crm/actions/update-crm-attribute.ts +111 -0
- package/corpus/templates/crm/actions/update-crm-list-entry.ts +102 -0
- package/corpus/templates/crm/actions/update-crm-list.ts +97 -0
- package/corpus/templates/crm/actions/update-crm-record.ts +543 -0
- package/corpus/templates/crm/actions/view-screen.ts +324 -0
- package/corpus/templates/crm/agent-native.app-skill.json +69 -0
- package/corpus/templates/crm/app/components/crm/CreateCrmRecordDialog.tsx +158 -0
- package/corpus/templates/crm/app/components/crm/CrmDashboardPanel.tsx +78 -0
- package/corpus/templates/crm/app/components/crm/CrmSignalsPanel.tsx +220 -0
- package/corpus/templates/crm/app/components/crm/IntelligenceSettings.tsx +414 -0
- package/corpus/templates/crm/app/components/crm/RecordActions.tsx +648 -0
- package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +488 -0
- package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +232 -0
- package/corpus/templates/crm/app/components/crm/SavedViewDataProgram.tsx +93 -0
- package/corpus/templates/crm/app/components/crm/Surface.tsx +97 -0
- package/corpus/templates/crm/app/components/crm/WorkOverview.tsx +180 -0
- package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +988 -0
- package/corpus/templates/crm/app/components/crm/board/board-model.ts +399 -0
- package/corpus/templates/crm/app/components/crm/board/entry-filter.ts +150 -0
- package/corpus/templates/crm/app/components/crm/board/view-draft.ts +227 -0
- package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +1033 -0
- package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +620 -0
- package/corpus/templates/crm/app/components/crm/grid/clipboard.ts +84 -0
- package/corpus/templates/crm/app/components/crm/grid/model.ts +304 -0
- package/corpus/templates/crm/app/components/crm/grid/navigation.ts +220 -0
- package/corpus/templates/crm/app/components/crm/grid/query.ts +214 -0
- package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +192 -0
- package/corpus/templates/crm/app/components/crm/record/FieldHistory.tsx +179 -0
- package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +103 -0
- package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +235 -0
- package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +206 -0
- package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +245 -0
- package/corpus/templates/crm/app/components/crm/record/record-data.ts +603 -0
- package/corpus/templates/crm/app/components/crm/settings/AdvancedSettings.tsx +49 -0
- package/corpus/templates/crm/app/components/crm/settings/AttributeOptionsEditor.tsx +441 -0
- package/corpus/templates/crm/app/components/crm/settings/ConnectionSettings.tsx +169 -0
- package/corpus/templates/crm/app/components/crm/settings/FieldsSettings.tsx +935 -0
- package/corpus/templates/crm/app/components/crm/settings/ListsSettings.tsx +339 -0
- package/corpus/templates/crm/app/components/crm/settings/settings-admin.ts +377 -0
- package/corpus/templates/crm/app/components/crm/shared/AttributeValueParts.tsx +69 -0
- package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +591 -0
- package/corpus/templates/crm/app/components/layout/CrmCommandMenu.tsx +556 -0
- package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +130 -0
- package/corpus/templates/crm/app/components/layout/CrmSidebar.tsx +407 -0
- package/corpus/templates/crm/app/components/layout/command-palette-search.ts +126 -0
- package/corpus/templates/crm/app/components/layout/crm-ui-intents.ts +41 -0
- package/corpus/templates/crm/app/components/layout/sidebar-collapse.ts +40 -0
- package/corpus/templates/crm/app/components/layout/sidebar-lists.ts +109 -0
- package/corpus/templates/crm/app/components/ui/alert-dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/badge.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/button.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/checkbox.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dialog.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/dropdown-menu.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/hover-card.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/input.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/label.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/popover.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/select.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/sheet.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/skeleton.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/table.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/tabs.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/textarea.tsx +1 -0
- package/corpus/templates/crm/app/components/ui/toolkit-provider.tsx +17 -0
- package/corpus/templates/crm/app/components/ui/tooltip.tsx +1 -0
- package/corpus/templates/crm/app/design-system.ts +3 -0
- package/corpus/templates/crm/app/entry.client.tsx +19 -0
- package/corpus/templates/crm/app/entry.server.tsx +10 -0
- package/corpus/templates/crm/app/global.css +167 -0
- package/corpus/templates/crm/app/hooks/use-keyboard-shortcuts.ts +206 -0
- package/corpus/templates/crm/app/hooks/use-navigation-state.ts +48 -0
- package/corpus/templates/crm/app/i18n/en-US.ts +684 -0
- package/corpus/templates/crm/app/i18n/index.ts +34 -0
- package/corpus/templates/crm/app/lib/dashboard.ts +13 -0
- package/corpus/templates/crm/app/lib/navigation.ts +17 -0
- package/corpus/templates/crm/app/lib/tab-id.ts +1 -0
- package/corpus/templates/crm/app/lib/types.ts +172 -0
- package/corpus/templates/crm/app/lib/utils.ts +1 -0
- package/corpus/templates/crm/app/root.tsx +93 -0
- package/corpus/templates/crm/app/routes/_index.tsx +26 -0
- package/corpus/templates/crm/app/routes/accounts.tsx +41 -0
- package/corpus/templates/crm/app/routes/agent.tsx +5 -0
- package/corpus/templates/crm/app/routes/ask.tsx +53 -0
- package/corpus/templates/crm/app/routes/dashboard.tsx +118 -0
- package/corpus/templates/crm/app/routes/lists.tsx +228 -0
- package/corpus/templates/crm/app/routes/opportunities.tsx +41 -0
- package/corpus/templates/crm/app/routes/people.tsx +41 -0
- package/corpus/templates/crm/app/routes/proposals.tsx +388 -0
- package/corpus/templates/crm/app/routes/records.$recordId.tsx +53 -0
- package/corpus/templates/crm/app/routes/records.tsx +64 -0
- package/corpus/templates/crm/app/routes/settings.tsx +118 -0
- package/corpus/templates/crm/app/routes/setup.tsx +308 -0
- package/corpus/templates/crm/app/routes/tasks.tsx +252 -0
- package/corpus/templates/crm/app/routes/views.tsx +683 -0
- package/corpus/templates/crm/app/routes.ts +19 -0
- package/corpus/templates/crm/app/vite-env.d.ts +6 -0
- 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
- package/corpus/templates/crm/changelog/2026-07-26-crm-now-finds-likely-duplicate-records-and-tells-you-exactly.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-enrichment-now-shows-what-a-run-will-cost-before-it-starts-g.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-lists-turn-any-set-of-records-into-a-pipeline-with-its-own-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-moving-a-record-or-list-entry-into-a-stage-that-was-retired-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-opening-a-record-now-shows-one-page-with-its-fields-the-list.md +6 -0
- 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
- package/corpus/templates/crm/changelog/2026-07-26-provider-record-edits-now-show-an-exact-before-after-diff-an.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-record-page-attribute-labels-now-read-in-full-instead-of-bei.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-records-now-use-typed-fields-currency-dates-ratings-status-s.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-retyping-a-value-on-the-record-page-now-replaces-it-instead-.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-settings-now-has-fields-and-lists-tabs-for-adding-fields-edi.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-proposal-review-now-shows-the-field-that-actually-change.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-26-the-record-grid-is-now-a-real-spreadsheet-filter-sort-and-pa.md +6 -0
- package/corpus/templates/crm/components.json +20 -0
- package/corpus/templates/crm/docs/architecture/crm-contract.md +214 -0
- package/corpus/templates/crm/docs/features/README.md +55 -0
- package/corpus/templates/crm/docs/features/f1-records-and-fields.mdx +52 -0
- package/corpus/templates/crm/docs/features/f2-lists-pipelines-and-views.mdx +48 -0
- package/corpus/templates/crm/docs/features/f3-work-and-record-quality.mdx +40 -0
- package/corpus/templates/crm/docs/features/f4-evidence-and-chat.mdx +47 -0
- package/corpus/templates/crm/learnings.defaults.md +5 -0
- package/corpus/templates/crm/netlify.toml +11 -0
- package/corpus/templates/crm/package.json +61 -0
- package/corpus/templates/crm/public/favicon.svg +1 -0
- package/corpus/templates/crm/public/icon-180.svg +1 -0
- package/corpus/templates/crm/public/manifest.json +11 -0
- package/corpus/templates/crm/react-router.config.ts +7 -0
- package/corpus/templates/crm/server/crm/adapter.ts +32 -0
- package/corpus/templates/crm/server/crm/crm-field-firewall.ts +64 -0
- package/corpus/templates/crm/server/crm/crm-mirror.ts +721 -0
- package/corpus/templates/crm/server/crm/field-policy-attributes.ts +48 -0
- package/corpus/templates/crm/server/crm/hubspot-adapter.ts +1146 -0
- package/corpus/templates/crm/server/crm/native-adapter.ts +1810 -0
- package/corpus/templates/crm/server/crm/provider-record-link.ts +264 -0
- package/corpus/templates/crm/server/crm/read-through.ts +207 -0
- package/corpus/templates/crm/server/crm/salesforce-adapter.ts +1255 -0
- package/corpus/templates/crm/server/db/crm-store.ts +1112 -0
- package/corpus/templates/crm/server/db/index.ts +191 -0
- package/corpus/templates/crm/server/db/schema.ts +647 -0
- package/corpus/templates/crm/server/lib/crm-query.ts +1558 -0
- package/corpus/templates/crm/server/lib/dedupe.ts +714 -0
- package/corpus/templates/crm/server/lib/enrichment-cost.ts +381 -0
- package/corpus/templates/crm/server/lib/enrichment-slots.ts +519 -0
- package/corpus/templates/crm/server/lib/intelligence/default-detectors.ts +65 -0
- package/corpus/templates/crm/server/lib/intelligence/evidence.ts +105 -0
- package/corpus/templates/crm/server/lib/intelligence/keyword-detector.ts +94 -0
- package/corpus/templates/crm/server/lib/intelligence/smart-detector.ts +39 -0
- package/corpus/templates/crm/server/lib/intelligence/summary.ts +19 -0
- package/corpus/templates/crm/server/lib/lifecycle.ts +634 -0
- package/corpus/templates/crm/server/lib/provider-api.ts +64 -0
- package/corpus/templates/crm/server/middleware/auth.ts +4 -0
- package/corpus/templates/crm/server/plugins/agent-chat.ts +65 -0
- package/corpus/templates/crm/server/plugins/auth.ts +14 -0
- package/corpus/templates/crm/server/plugins/core-routes.ts +17 -0
- package/corpus/templates/crm/server/plugins/db.ts +738 -0
- package/corpus/templates/crm/server/routes/[...page].get.ts +5 -0
- package/corpus/templates/crm/shared/crm-attributes.ts +469 -0
- package/corpus/templates/crm/shared/crm-automation-recipes.ts +132 -0
- package/corpus/templates/crm/shared/crm-contract.ts +417 -0
- package/corpus/templates/crm/shared/crm-navigation.ts +190 -0
- package/corpus/templates/crm/shared/crm-sales-config.ts +63 -0
- package/corpus/templates/crm/ssr-entry.ts +11 -0
- package/corpus/templates/crm/tsconfig.json +24 -0
- package/corpus/templates/crm/vite.config.ts +15 -0
- package/corpus/templates/crm/vitest.config.ts +5 -0
- package/corpus/templates/design/app/i18n-data.ts +12 -12
- package/corpus/templates/design/changelog/2026-07-26-apply-design-updates-from-a-centered-top-toolbar-without-cov.md +6 -0
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +12 -0
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/cli/templates-meta.ts +12 -0
|
@@ -0,0 +1,1558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CRM grid's server-side query: a typed filter/sort tree compiled into one
|
|
3
|
+
* dialect-agnostic Drizzle statement.
|
|
4
|
+
*
|
|
5
|
+
* Two rules shape everything here.
|
|
6
|
+
*
|
|
7
|
+
* 1. A filter that cannot be compiled is an ERROR, never a dropped condition.
|
|
8
|
+
* Silently ignoring an unknown attribute returns a full unfiltered page that
|
|
9
|
+
* looks exactly like a correct answer, which is the failure mode the root
|
|
10
|
+
* CLAUDE.md forbids.
|
|
11
|
+
* 2. Attribute values live in `crm_record_fields`, one bitemporal row per
|
|
12
|
+
* attribute with `active_until IS NULL` for the current value. Filtering is
|
|
13
|
+
* therefore EXISTS/NOT EXISTS against that table, and sorting is a correlated
|
|
14
|
+
* scalar subquery — never `json_extract`, which diverges across dialects.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
18
|
+
import {
|
|
19
|
+
and,
|
|
20
|
+
eq,
|
|
21
|
+
exists,
|
|
22
|
+
inArray,
|
|
23
|
+
isNull,
|
|
24
|
+
notExists,
|
|
25
|
+
or,
|
|
26
|
+
sql,
|
|
27
|
+
type SQL,
|
|
28
|
+
} from "drizzle-orm";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
ATTRIBUTE_TYPE_SPECS,
|
|
33
|
+
type CrmAttributeType,
|
|
34
|
+
} from "../../shared/crm-attributes.js";
|
|
35
|
+
import {
|
|
36
|
+
CRM_CURRENT_USER_TOKEN,
|
|
37
|
+
type CrmAccessScope,
|
|
38
|
+
type CrmObjectKind,
|
|
39
|
+
} from "../../shared/crm-contract.js";
|
|
40
|
+
import {
|
|
41
|
+
createConnectedCrmAdapter,
|
|
42
|
+
isConnectedCrmProvider,
|
|
43
|
+
} from "../crm/adapter.js";
|
|
44
|
+
import { resolveNativeCrmAccessScope } from "../crm/native-adapter.js";
|
|
45
|
+
import {
|
|
46
|
+
parseCrmAccessScope,
|
|
47
|
+
scopesAreCompatible,
|
|
48
|
+
} from "../crm/read-through.js";
|
|
49
|
+
import { getDb, schema } from "../db/index.js";
|
|
50
|
+
|
|
51
|
+
const MAX_RECORD_LIMIT = 100;
|
|
52
|
+
const MAX_SCOPE_VALIDATIONS = 20;
|
|
53
|
+
|
|
54
|
+
/** A filter the caller must fix before retrying — surfaces as HTTP 422. */
|
|
55
|
+
export class CrmFilterError extends Error {
|
|
56
|
+
readonly statusCode = 422;
|
|
57
|
+
readonly code: string;
|
|
58
|
+
|
|
59
|
+
constructor(code: string, message: string) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "CrmFilterError";
|
|
62
|
+
this.code = code;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A cursor that does not belong to this query — surfaces as HTTP 400. */
|
|
67
|
+
export class CrmCursorError extends Error {
|
|
68
|
+
readonly statusCode = 400;
|
|
69
|
+
readonly code = "crm-cursor-mismatch";
|
|
70
|
+
|
|
71
|
+
constructor(message: string) {
|
|
72
|
+
super(message);
|
|
73
|
+
this.name = "CrmCursorError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Input shapes
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
export const CRM_FILTER_CONDITIONS = [
|
|
82
|
+
"is",
|
|
83
|
+
"is-not",
|
|
84
|
+
"contains",
|
|
85
|
+
"not-contains",
|
|
86
|
+
"starts-with",
|
|
87
|
+
"ends-with",
|
|
88
|
+
"is-empty",
|
|
89
|
+
"is-not-empty",
|
|
90
|
+
"=",
|
|
91
|
+
"!=",
|
|
92
|
+
">",
|
|
93
|
+
">=",
|
|
94
|
+
"<",
|
|
95
|
+
"<=",
|
|
96
|
+
"between",
|
|
97
|
+
"before",
|
|
98
|
+
"after",
|
|
99
|
+
"is-any-of",
|
|
100
|
+
"is-none-of",
|
|
101
|
+
] as const;
|
|
102
|
+
|
|
103
|
+
export type CrmFilterCondition = (typeof CRM_FILTER_CONDITIONS)[number];
|
|
104
|
+
|
|
105
|
+
const filterValueSchema = z.union([
|
|
106
|
+
z.string().max(2_000),
|
|
107
|
+
z.number(),
|
|
108
|
+
z.boolean(),
|
|
109
|
+
z.null(),
|
|
110
|
+
z.array(z.union([z.string().max(2_000), z.number(), z.boolean()])).max(50),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const leafSchema = z.object({
|
|
114
|
+
/** Attribute id, `api_slug`, or `field_name` on `crm_field_policies`. */
|
|
115
|
+
attributeId: z.string().trim().min(1).max(128).optional(),
|
|
116
|
+
/** A column on `crm_records` — see RECORD_COLUMNS. */
|
|
117
|
+
field: z.string().trim().min(1).max(64).optional(),
|
|
118
|
+
condition: z.enum(CRM_FILTER_CONDITIONS),
|
|
119
|
+
value: filterValueSchema.optional(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const groupSchema = z.object({
|
|
123
|
+
op: z.enum(["and", "or"]).default("and"),
|
|
124
|
+
conditions: z.array(leafSchema).min(1).max(30),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One level of nesting, spelled out rather than recursive: an unbounded tree is
|
|
129
|
+
* an unbounded number of correlated subqueries in one statement.
|
|
130
|
+
*/
|
|
131
|
+
export const crmFilterSchema = z.object({
|
|
132
|
+
op: z.enum(["and", "or"]).default("and"),
|
|
133
|
+
conditions: z.array(z.union([groupSchema, leafSchema])).max(30),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const crmSortSchema = z
|
|
137
|
+
.array(
|
|
138
|
+
z.object({
|
|
139
|
+
attributeId: z.string().trim().min(1).max(128).optional(),
|
|
140
|
+
field: z.string().trim().min(1).max(64).optional(),
|
|
141
|
+
direction: z.enum(["asc", "desc"]).default("asc"),
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
.max(4);
|
|
145
|
+
|
|
146
|
+
export type CrmFilterLeaf = z.infer<typeof leafSchema>;
|
|
147
|
+
export type CrmFilterGroup = z.infer<typeof groupSchema>;
|
|
148
|
+
export type CrmFilter = z.infer<typeof crmFilterSchema>;
|
|
149
|
+
export type CrmSort = z.infer<typeof crmSortSchema>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The tree the compiler walks. Input is capped at one level of nesting by the
|
|
153
|
+
* schema; this shape lets the query builder wrap an already-parsed filter in
|
|
154
|
+
* another group without re-typing it.
|
|
155
|
+
*/
|
|
156
|
+
export interface CrmFilterNode {
|
|
157
|
+
op: "and" | "or";
|
|
158
|
+
conditions: Array<CrmFilterLeaf | CrmFilterNode>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Targets: an attribute value or a `crm_records` column
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The condition vocabulary a target accepts. Derived from the attribute-type
|
|
167
|
+
* registry so a new attribute type inherits a family instead of needing a new
|
|
168
|
+
* branch at every call site.
|
|
169
|
+
*/
|
|
170
|
+
type ConditionFamily =
|
|
171
|
+
| "text"
|
|
172
|
+
| "number"
|
|
173
|
+
| "date"
|
|
174
|
+
| "boolean"
|
|
175
|
+
| "option"
|
|
176
|
+
| "reference"
|
|
177
|
+
| "json";
|
|
178
|
+
|
|
179
|
+
const FAMILY_CONDITIONS: Record<
|
|
180
|
+
ConditionFamily,
|
|
181
|
+
readonly CrmFilterCondition[]
|
|
182
|
+
> = {
|
|
183
|
+
text: [
|
|
184
|
+
"is",
|
|
185
|
+
"is-not",
|
|
186
|
+
"contains",
|
|
187
|
+
"not-contains",
|
|
188
|
+
"starts-with",
|
|
189
|
+
"ends-with",
|
|
190
|
+
"is-empty",
|
|
191
|
+
"is-not-empty",
|
|
192
|
+
],
|
|
193
|
+
number: [
|
|
194
|
+
"=",
|
|
195
|
+
"!=",
|
|
196
|
+
">",
|
|
197
|
+
">=",
|
|
198
|
+
"<",
|
|
199
|
+
"<=",
|
|
200
|
+
"between",
|
|
201
|
+
"is-empty",
|
|
202
|
+
"is-not-empty",
|
|
203
|
+
],
|
|
204
|
+
date: ["is", "before", "after", "between", "is-empty", "is-not-empty"],
|
|
205
|
+
boolean: ["is"],
|
|
206
|
+
option: ["is", "is-any-of", "is-none-of", "is-empty", "is-not-empty"],
|
|
207
|
+
reference: ["is", "is-any-of", "is-empty", "is-not-empty"],
|
|
208
|
+
json: ["is-empty", "is-not-empty"],
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
function conditionFamily(type: CrmAttributeType): ConditionFamily {
|
|
212
|
+
const spec = ATTRIBUTE_TYPE_SPECS[type];
|
|
213
|
+
if (spec.usesOptions) return "option";
|
|
214
|
+
if (type === "date" || type === "timestamp") return "date";
|
|
215
|
+
if (type === "record-reference" || type === "actor-reference") {
|
|
216
|
+
return "reference";
|
|
217
|
+
}
|
|
218
|
+
if (spec.storageColumn === "numberValue") return "number";
|
|
219
|
+
if (spec.storageColumn === "booleanValue") return "boolean";
|
|
220
|
+
if (spec.storageColumn === "jsonValue") return "json";
|
|
221
|
+
return "text";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface RecordColumnSpec {
|
|
225
|
+
column: SQL;
|
|
226
|
+
family: ConditionFamily;
|
|
227
|
+
/** Compared case-insensitively and eligible for `@currentUser`. */
|
|
228
|
+
actor?: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Filterable/sortable columns on `crm_records`. An allow-list, not a lookup by
|
|
233
|
+
* string: an unlisted name is a typed error, so a filter can never reach a
|
|
234
|
+
* column the summary does not expose.
|
|
235
|
+
*/
|
|
236
|
+
const RECORD_COLUMNS: Record<string, RecordColumnSpec> = {
|
|
237
|
+
displayName: {
|
|
238
|
+
column: sql`${schema.crmRecords.displayName}`,
|
|
239
|
+
family: "text",
|
|
240
|
+
},
|
|
241
|
+
kind: { column: sql`${schema.crmRecords.kind}`, family: "text" },
|
|
242
|
+
objectType: { column: sql`${schema.crmRecords.objectType}`, family: "text" },
|
|
243
|
+
provider: { column: sql`${schema.crmRecords.provider}`, family: "text" },
|
|
244
|
+
connectionId: {
|
|
245
|
+
column: sql`${schema.crmRecords.connectionId}`,
|
|
246
|
+
family: "text",
|
|
247
|
+
},
|
|
248
|
+
primaryEmail: {
|
|
249
|
+
column: sql`${schema.crmRecords.primaryEmail}`,
|
|
250
|
+
family: "text",
|
|
251
|
+
},
|
|
252
|
+
domain: { column: sql`${schema.crmRecords.domain}`, family: "text" },
|
|
253
|
+
stage: { column: sql`${schema.crmRecords.stage}`, family: "text" },
|
|
254
|
+
pipelineName: {
|
|
255
|
+
column: sql`${schema.crmRecords.pipelineName}`,
|
|
256
|
+
family: "text",
|
|
257
|
+
},
|
|
258
|
+
ownerName: { column: sql`${schema.crmRecords.ownerName}`, family: "text" },
|
|
259
|
+
ownerRemoteId: {
|
|
260
|
+
column: sql`${schema.crmRecords.ownerRemoteId}`,
|
|
261
|
+
family: "text",
|
|
262
|
+
},
|
|
263
|
+
ownerEmail: {
|
|
264
|
+
column: sql`${schema.crmRecords.ownerEmail}`,
|
|
265
|
+
family: "text",
|
|
266
|
+
actor: true,
|
|
267
|
+
},
|
|
268
|
+
amount: { column: sql`${schema.crmRecords.amount}`, family: "number" },
|
|
269
|
+
currencyCode: {
|
|
270
|
+
column: sql`${schema.crmRecords.currencyCode}`,
|
|
271
|
+
family: "text",
|
|
272
|
+
},
|
|
273
|
+
desiredCadenceDays: {
|
|
274
|
+
column: sql`${schema.crmRecords.desiredCadenceDays}`,
|
|
275
|
+
family: "number",
|
|
276
|
+
},
|
|
277
|
+
closeDate: { column: sql`${schema.crmRecords.closeDate}`, family: "date" },
|
|
278
|
+
nextContactAt: {
|
|
279
|
+
column: sql`${schema.crmRecords.nextContactAt}`,
|
|
280
|
+
family: "date",
|
|
281
|
+
},
|
|
282
|
+
lastMeaningfulInteractionAt: {
|
|
283
|
+
column: sql`${schema.crmRecords.lastMeaningfulInteractionAt}`,
|
|
284
|
+
family: "date",
|
|
285
|
+
},
|
|
286
|
+
remoteUpdatedAt: {
|
|
287
|
+
column: sql`${schema.crmRecords.remoteUpdatedAt}`,
|
|
288
|
+
family: "date",
|
|
289
|
+
},
|
|
290
|
+
createdAt: { column: sql`${schema.crmRecords.createdAt}`, family: "date" },
|
|
291
|
+
updatedAt: { column: sql`${schema.crmRecords.updatedAt}`, family: "date" },
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
interface ResolvedAttribute {
|
|
295
|
+
id: string;
|
|
296
|
+
apiSlug: string;
|
|
297
|
+
attributeType: CrmAttributeType;
|
|
298
|
+
multi: boolean;
|
|
299
|
+
family: ConditionFamily;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type ResolvedTarget =
|
|
303
|
+
| { kind: "attribute"; label: string; attribute: ResolvedAttribute }
|
|
304
|
+
| { kind: "column"; label: string; spec: RecordColumnSpec };
|
|
305
|
+
|
|
306
|
+
function targetFamily(target: ResolvedTarget): ConditionFamily {
|
|
307
|
+
return target.kind === "attribute"
|
|
308
|
+
? target.attribute.family
|
|
309
|
+
: target.spec.family;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isActorTarget(target: ResolvedTarget): boolean {
|
|
313
|
+
return target.kind === "attribute"
|
|
314
|
+
? target.attribute.attributeType === "actor-reference"
|
|
315
|
+
: target.spec.actor === true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Attribute resolution — one query for every reference in the tree
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
interface AttributeRow {
|
|
323
|
+
id: string;
|
|
324
|
+
fieldName: string;
|
|
325
|
+
apiSlug: string | null;
|
|
326
|
+
attributeType: CrmAttributeType;
|
|
327
|
+
multi: boolean;
|
|
328
|
+
archived: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function collectRefs(node: CrmFilterNode, refs: Set<string>): void {
|
|
332
|
+
for (const entry of node.conditions) {
|
|
333
|
+
if ("conditions" in entry) collectRefs(entry, refs);
|
|
334
|
+
else if (entry.attributeId) refs.add(entry.attributeId);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function attributeRefs(
|
|
339
|
+
filter: CrmFilterNode | undefined,
|
|
340
|
+
sort: CrmSort,
|
|
341
|
+
): string[] {
|
|
342
|
+
const refs = new Set<string>();
|
|
343
|
+
if (filter) collectRefs(filter, refs);
|
|
344
|
+
for (const entry of sort) {
|
|
345
|
+
if (entry.attributeId) refs.add(entry.attributeId);
|
|
346
|
+
}
|
|
347
|
+
return Array.from(refs);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Resolve every attribute reference in one pass. A reference may name the
|
|
352
|
+
* attribute id, its `api_slug`, or its legacy `field_name`; unknown, archived,
|
|
353
|
+
* and ambiguous references are all typed errors that name the reference.
|
|
354
|
+
*/
|
|
355
|
+
async function resolveAttributes(
|
|
356
|
+
refs: string[],
|
|
357
|
+
): Promise<Map<string, ResolvedAttribute>> {
|
|
358
|
+
const resolved = new Map<string, ResolvedAttribute>();
|
|
359
|
+
if (refs.length === 0) return resolved;
|
|
360
|
+
|
|
361
|
+
const rows: AttributeRow[] = await getDb()
|
|
362
|
+
.select({
|
|
363
|
+
id: schema.crmFieldPolicies.id,
|
|
364
|
+
fieldName: schema.crmFieldPolicies.fieldName,
|
|
365
|
+
apiSlug: schema.crmFieldPolicies.apiSlug,
|
|
366
|
+
attributeType: schema.crmFieldPolicies.attributeType,
|
|
367
|
+
multi: schema.crmFieldPolicies.multi,
|
|
368
|
+
archived: schema.crmFieldPolicies.archived,
|
|
369
|
+
})
|
|
370
|
+
.from(schema.crmFieldPolicies)
|
|
371
|
+
.where(
|
|
372
|
+
and(
|
|
373
|
+
or(
|
|
374
|
+
inArray(schema.crmFieldPolicies.id, refs),
|
|
375
|
+
inArray(schema.crmFieldPolicies.apiSlug, refs),
|
|
376
|
+
inArray(schema.crmFieldPolicies.fieldName, refs),
|
|
377
|
+
),
|
|
378
|
+
accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
for (const ref of refs) {
|
|
383
|
+
const matches = rows.filter(
|
|
384
|
+
(row) => row.id === ref || row.apiSlug === ref || row.fieldName === ref,
|
|
385
|
+
);
|
|
386
|
+
if (matches.length === 0) {
|
|
387
|
+
throw new CrmFilterError(
|
|
388
|
+
"crm-filter-unknown-attribute",
|
|
389
|
+
`Filter references unknown attribute "${ref}".`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const live = matches.filter((row) => !row.archived);
|
|
393
|
+
if (live.length === 0) {
|
|
394
|
+
throw new CrmFilterError(
|
|
395
|
+
"crm-filter-archived-attribute",
|
|
396
|
+
`Filter references archived attribute "${ref}".`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
// The same slug can exist on several objects/connections. That is fine as
|
|
400
|
+
// long as they agree on how the value is stored — the predicate keys on the
|
|
401
|
+
// slug, not on one row's id.
|
|
402
|
+
const shapes = new Set(
|
|
403
|
+
live.map((row) => `${row.attributeType}:${row.multi ? 1 : 0}`),
|
|
404
|
+
);
|
|
405
|
+
if (shapes.size > 1) {
|
|
406
|
+
throw new CrmFilterError(
|
|
407
|
+
"crm-filter-ambiguous-attribute",
|
|
408
|
+
`Attribute "${ref}" resolves to more than one attribute type; filter by attribute id instead.`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const first = live[0]!;
|
|
412
|
+
resolved.set(ref, {
|
|
413
|
+
id: first.id,
|
|
414
|
+
apiSlug: first.apiSlug ?? first.fieldName,
|
|
415
|
+
attributeType: first.attributeType,
|
|
416
|
+
multi: first.multi,
|
|
417
|
+
family: conditionFamily(first.attributeType),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return resolved;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function resolveTarget(
|
|
424
|
+
ref: { attributeId?: string; field?: string },
|
|
425
|
+
attributes: Map<string, ResolvedAttribute>,
|
|
426
|
+
): ResolvedTarget {
|
|
427
|
+
if (ref.attributeId && ref.field) {
|
|
428
|
+
throw new CrmFilterError(
|
|
429
|
+
"crm-filter-target",
|
|
430
|
+
`Filter entry sets both attributeId "${ref.attributeId}" and field "${ref.field}"; pick one.`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (ref.attributeId) {
|
|
434
|
+
const attribute = attributes.get(ref.attributeId);
|
|
435
|
+
if (!attribute) {
|
|
436
|
+
throw new CrmFilterError(
|
|
437
|
+
"crm-filter-unknown-attribute",
|
|
438
|
+
`Filter references unknown attribute "${ref.attributeId}".`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return { kind: "attribute", label: ref.attributeId, attribute };
|
|
442
|
+
}
|
|
443
|
+
if (ref.field) {
|
|
444
|
+
const spec = RECORD_COLUMNS[ref.field];
|
|
445
|
+
if (!spec) {
|
|
446
|
+
throw new CrmFilterError(
|
|
447
|
+
"crm-filter-unknown-field",
|
|
448
|
+
`Filter references unknown record field "${ref.field}". Known fields: ${Object.keys(
|
|
449
|
+
RECORD_COLUMNS,
|
|
450
|
+
).join(", ")}.`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return { kind: "column", label: ref.field, spec };
|
|
454
|
+
}
|
|
455
|
+
throw new CrmFilterError(
|
|
456
|
+
"crm-filter-target",
|
|
457
|
+
"Every filter and sort entry needs either an attributeId or a field.",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Values
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
type Scalar = string | number | boolean;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* `@currentUser` is resolved here and nowhere else. Resolving it on the client
|
|
469
|
+
* would make one shared view render the author's rows for everybody.
|
|
470
|
+
*/
|
|
471
|
+
function resolveToken(
|
|
472
|
+
value: Scalar,
|
|
473
|
+
target: ResolvedTarget,
|
|
474
|
+
actorEmail: string | null,
|
|
475
|
+
): Scalar {
|
|
476
|
+
if (value !== CRM_CURRENT_USER_TOKEN) return value;
|
|
477
|
+
if (!isActorTarget(target)) {
|
|
478
|
+
throw new CrmFilterError(
|
|
479
|
+
"crm-filter-current-user-target",
|
|
480
|
+
`"${CRM_CURRENT_USER_TOKEN}" only resolves on an actor-reference attribute or an owner field, not on "${target.label}".`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (!actorEmail) {
|
|
484
|
+
throw new CrmFilterError(
|
|
485
|
+
"crm-filter-no-actor",
|
|
486
|
+
`"${CRM_CURRENT_USER_TOKEN}" cannot be resolved without a signed-in caller.`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return actorEmail.toLowerCase();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function requireValue(
|
|
493
|
+
leaf: CrmFilterLeaf,
|
|
494
|
+
target: ResolvedTarget,
|
|
495
|
+
actorEmail: string | null,
|
|
496
|
+
): Scalar {
|
|
497
|
+
const value = leaf.value;
|
|
498
|
+
if (value === undefined || value === null || Array.isArray(value)) {
|
|
499
|
+
throw new CrmFilterError(
|
|
500
|
+
"crm-filter-value",
|
|
501
|
+
`Condition "${leaf.condition}" on "${target.label}" needs a single value.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return resolveToken(value, target, actorEmail);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function requireValues(
|
|
508
|
+
leaf: CrmFilterLeaf,
|
|
509
|
+
target: ResolvedTarget,
|
|
510
|
+
actorEmail: string | null,
|
|
511
|
+
): Scalar[] {
|
|
512
|
+
const value = leaf.value;
|
|
513
|
+
const list = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
514
|
+
if (list.length === 0) {
|
|
515
|
+
throw new CrmFilterError(
|
|
516
|
+
"crm-filter-value",
|
|
517
|
+
`Condition "${leaf.condition}" on "${target.label}" needs at least one value.`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
return list.map((entry) => resolveToken(entry, target, actorEmail));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function requireRange(
|
|
524
|
+
leaf: CrmFilterLeaf,
|
|
525
|
+
target: ResolvedTarget,
|
|
526
|
+
): [Scalar, Scalar] {
|
|
527
|
+
const value = leaf.value;
|
|
528
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
529
|
+
throw new CrmFilterError(
|
|
530
|
+
"crm-filter-value",
|
|
531
|
+
`Condition "between" on "${target.label}" needs a two-element [from, to] value.`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return [value[0]!, value[1]!];
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function requireNumber(value: Scalar, target: ResolvedTarget): number {
|
|
538
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
539
|
+
if (!Number.isFinite(parsed)) {
|
|
540
|
+
throw new CrmFilterError(
|
|
541
|
+
"crm-filter-value",
|
|
542
|
+
`"${target.label}" is numeric; "${String(value)}" is not a number.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return parsed;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Relative dates
|
|
550
|
+
//
|
|
551
|
+
// Boundaries are emitted as bare `YYYY-MM-DD`, which compares correctly against
|
|
552
|
+
// both a `date` value ("2026-07-26") and a `timestamp` value
|
|
553
|
+
// ("2026-07-26T13:00:00.000Z") under lexicographic ordering. Days are UTC: the
|
|
554
|
+
// server has no caller timezone, and inventing one would silently shift every
|
|
555
|
+
// "today" filter for half the world.
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
const RELATIVE_DAYS = /^(last|next)-(\d{1,3})-days$/;
|
|
559
|
+
|
|
560
|
+
function isoDay(time: number): string {
|
|
561
|
+
return new Date(time).toISOString().slice(0, 10);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const DAY_MS = 86_400_000;
|
|
565
|
+
|
|
566
|
+
export function resolveRelativeDateToken(
|
|
567
|
+
token: string,
|
|
568
|
+
now: Date,
|
|
569
|
+
): { from: string; to: string } | null {
|
|
570
|
+
const startOfDay = Date.UTC(
|
|
571
|
+
now.getUTCFullYear(),
|
|
572
|
+
now.getUTCMonth(),
|
|
573
|
+
now.getUTCDate(),
|
|
574
|
+
);
|
|
575
|
+
if (token === "today") {
|
|
576
|
+
return { from: isoDay(startOfDay), to: isoDay(startOfDay + DAY_MS) };
|
|
577
|
+
}
|
|
578
|
+
if (token === "this-week") {
|
|
579
|
+
// ISO weeks start Monday; getUTCDay() is 0 for Sunday.
|
|
580
|
+
const weekday = (new Date(startOfDay).getUTCDay() + 6) % 7;
|
|
581
|
+
const start = startOfDay - weekday * DAY_MS;
|
|
582
|
+
return { from: isoDay(start), to: isoDay(start + 7 * DAY_MS) };
|
|
583
|
+
}
|
|
584
|
+
const match = RELATIVE_DAYS.exec(token);
|
|
585
|
+
if (!match) return null;
|
|
586
|
+
const days = Number.parseInt(match[2]!, 10);
|
|
587
|
+
if (days < 1) {
|
|
588
|
+
throw new CrmFilterError(
|
|
589
|
+
"crm-filter-value",
|
|
590
|
+
`Relative date "${token}" needs a positive day count.`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
return match[1] === "last"
|
|
594
|
+
? {
|
|
595
|
+
from: isoDay(startOfDay - (days - 1) * DAY_MS),
|
|
596
|
+
to: isoDay(startOfDay + DAY_MS),
|
|
597
|
+
}
|
|
598
|
+
: { from: isoDay(startOfDay), to: isoDay(startOfDay + days * DAY_MS) };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Predicate compilation
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
function likePattern(value: Scalar, mode: "contains" | "prefix" | "suffix") {
|
|
606
|
+
const escaped = String(value).replace(/([\\%_])/g, "\\$1");
|
|
607
|
+
if (mode === "prefix") return `${escaped}%`;
|
|
608
|
+
if (mode === "suffix") return `%${escaped}`;
|
|
609
|
+
return `%${escaped}%`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// LIKE is case-insensitive in SQLite and case-sensitive in PostgreSQL. Lowering
|
|
613
|
+
// both sides is the only portable way to make "contains" mean the same thing.
|
|
614
|
+
function likeSql(column: SQL, pattern: string): SQL {
|
|
615
|
+
return sql`lower(${column}) like lower(${pattern}) escape '\\'`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Membership in a multi-valued attribute, whose set is stored as one canonical
|
|
620
|
+
* JSON array in `json_value`.
|
|
621
|
+
*
|
|
622
|
+
* The delimiters matter: an element is JSON-encoded, so its own quotes are
|
|
623
|
+
* escaped as `\"`. Searching for `,"acme",` (with the surrounding separator)
|
|
624
|
+
* therefore cannot match inside another element, while a bare `%"acme"%` could.
|
|
625
|
+
*
|
|
626
|
+
* ponytail: a text scan, not an index probe. Fine at CRM row counts; if a
|
|
627
|
+
* workspace ever filters millions of rows on a multi attribute, normalize the
|
|
628
|
+
* set into its own value rows instead of widening this.
|
|
629
|
+
*/
|
|
630
|
+
function jsonArrayContains(value: Scalar): SQL {
|
|
631
|
+
const encoded = JSON.stringify(String(value));
|
|
632
|
+
const column = sql`${schema.crmRecordFields.jsonValue}`;
|
|
633
|
+
return or(
|
|
634
|
+
likeSql(column, likePattern(`[${encoded},`, "contains")),
|
|
635
|
+
likeSql(column, likePattern(`,${encoded},`, "contains")),
|
|
636
|
+
likeSql(column, likePattern(`,${encoded}]`, "contains")),
|
|
637
|
+
likeSql(column, likePattern(`[${encoded}]`, "contains")),
|
|
638
|
+
)!;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The `crm_record_fields` column a target's values live in. */
|
|
642
|
+
function valueColumnFor(attribute: ResolvedAttribute): SQL {
|
|
643
|
+
if (attribute.multi) return sql`${schema.crmRecordFields.jsonValue}`;
|
|
644
|
+
const storage = ATTRIBUTE_TYPE_SPECS[attribute.attributeType].storageColumn;
|
|
645
|
+
if (storage === "numberValue")
|
|
646
|
+
return sql`${schema.crmRecordFields.numberValue}`;
|
|
647
|
+
if (storage === "booleanValue") {
|
|
648
|
+
return sql`${schema.crmRecordFields.booleanValue}`;
|
|
649
|
+
}
|
|
650
|
+
if (storage === "jsonValue") return sql`${schema.crmRecordFields.jsonValue}`;
|
|
651
|
+
return sql`${schema.crmRecordFields.stringValue}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function assertConditionSupported(
|
|
655
|
+
target: ResolvedTarget,
|
|
656
|
+
condition: CrmFilterCondition,
|
|
657
|
+
): void {
|
|
658
|
+
const family = targetFamily(target);
|
|
659
|
+
if (!FAMILY_CONDITIONS[family].includes(condition)) {
|
|
660
|
+
throw new CrmFilterError(
|
|
661
|
+
"crm-filter-condition",
|
|
662
|
+
`Condition "${condition}" is not available on "${target.label}" (${family}). Available: ${FAMILY_CONDITIONS[
|
|
663
|
+
family
|
|
664
|
+
].join(", ")}.`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (
|
|
668
|
+
target.kind === "attribute" &&
|
|
669
|
+
target.attribute.multi &&
|
|
670
|
+
!MULTI_CONDITIONS.includes(condition)
|
|
671
|
+
) {
|
|
672
|
+
throw new CrmFilterError(
|
|
673
|
+
"crm-filter-multi-condition",
|
|
674
|
+
`"${target.label}" is multi-valued; condition "${condition}" would compare against the whole set. Available: ${MULTI_CONDITIONS.join(", ")}.`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const MULTI_CONDITIONS: readonly CrmFilterCondition[] = [
|
|
680
|
+
"is",
|
|
681
|
+
"is-not",
|
|
682
|
+
"is-any-of",
|
|
683
|
+
"is-none-of",
|
|
684
|
+
"is-empty",
|
|
685
|
+
"is-not-empty",
|
|
686
|
+
];
|
|
687
|
+
|
|
688
|
+
interface CompileContext {
|
|
689
|
+
attributes: Map<string, ResolvedAttribute>;
|
|
690
|
+
actorEmail: string | null;
|
|
691
|
+
now: Date;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* A positive predicate over one value column, plus whether the caller must
|
|
696
|
+
* negate the surrounding EXISTS. `is-not` means "has no such value", which
|
|
697
|
+
* includes records that have no value at all — so it is NOT EXISTS of the
|
|
698
|
+
* positive test, not EXISTS of an inequality.
|
|
699
|
+
*/
|
|
700
|
+
interface Predicate {
|
|
701
|
+
sql: SQL;
|
|
702
|
+
negate: boolean;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function comparePredicate(
|
|
706
|
+
column: SQL,
|
|
707
|
+
target: ResolvedTarget,
|
|
708
|
+
leaf: CrmFilterLeaf,
|
|
709
|
+
ctx: CompileContext,
|
|
710
|
+
): Predicate {
|
|
711
|
+
const family = targetFamily(target);
|
|
712
|
+
const condition = leaf.condition;
|
|
713
|
+
const multi = target.kind === "attribute" ? target.attribute.multi : false;
|
|
714
|
+
|
|
715
|
+
if (condition === "is-empty") {
|
|
716
|
+
return { sql: sql`${column} is not null`, negate: true };
|
|
717
|
+
}
|
|
718
|
+
if (condition === "is-not-empty") {
|
|
719
|
+
return { sql: sql`${column} is not null`, negate: false };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (multi) {
|
|
723
|
+
if (condition === "is" || condition === "is-not") {
|
|
724
|
+
const value = requireValue(leaf, target, ctx.actorEmail);
|
|
725
|
+
return { sql: jsonArrayContains(value), negate: condition === "is-not" };
|
|
726
|
+
}
|
|
727
|
+
const values = requireValues(leaf, target, ctx.actorEmail);
|
|
728
|
+
return {
|
|
729
|
+
sql: or(...values.map((value) => jsonArrayContains(value)))!,
|
|
730
|
+
negate: condition === "is-none-of",
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (family === "boolean") {
|
|
735
|
+
const value = requireValue(leaf, target, ctx.actorEmail);
|
|
736
|
+
const wanted =
|
|
737
|
+
typeof value === "boolean" ? value : value === "true" || value === 1;
|
|
738
|
+
// `portableBoolean` is an INTEGER column in both dialects, and a raw `sql`
|
|
739
|
+
// fragment bypasses the column codec that would have converted this.
|
|
740
|
+
return { sql: sql`${column} = ${wanted ? 1 : 0}`, negate: false };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (family === "number") {
|
|
744
|
+
if (condition === "between") {
|
|
745
|
+
const [from, to] = requireRange(leaf, target);
|
|
746
|
+
return {
|
|
747
|
+
sql: and(
|
|
748
|
+
sql`${column} >= ${requireNumber(from, target)}`,
|
|
749
|
+
sql`${column} <= ${requireNumber(to, target)}`,
|
|
750
|
+
)!,
|
|
751
|
+
negate: false,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const value = requireNumber(
|
|
755
|
+
requireValue(leaf, target, ctx.actorEmail),
|
|
756
|
+
target,
|
|
757
|
+
);
|
|
758
|
+
if (condition === "=")
|
|
759
|
+
return { sql: sql`${column} = ${value}`, negate: false };
|
|
760
|
+
if (condition === "!=")
|
|
761
|
+
return { sql: sql`${column} = ${value}`, negate: true };
|
|
762
|
+
if (condition === ">")
|
|
763
|
+
return { sql: sql`${column} > ${value}`, negate: false };
|
|
764
|
+
if (condition === ">=")
|
|
765
|
+
return { sql: sql`${column} >= ${value}`, negate: false };
|
|
766
|
+
if (condition === "<")
|
|
767
|
+
return { sql: sql`${column} < ${value}`, negate: false };
|
|
768
|
+
return { sql: sql`${column} <= ${value}`, negate: false };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (family === "date") {
|
|
772
|
+
return datePredicate(column, target, leaf, ctx);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// text / option / reference
|
|
776
|
+
if (condition === "is" || condition === "is-not") {
|
|
777
|
+
const value = requireValue(leaf, target, ctx.actorEmail);
|
|
778
|
+
return {
|
|
779
|
+
sql: isActorTarget(target)
|
|
780
|
+
? sql`lower(${column}) = lower(${String(value)})`
|
|
781
|
+
: sql`${column} = ${value}`,
|
|
782
|
+
negate: condition === "is-not",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
if (condition === "is-any-of" || condition === "is-none-of") {
|
|
786
|
+
const values = requireValues(leaf, target, ctx.actorEmail);
|
|
787
|
+
const comparisons = values.map((value) =>
|
|
788
|
+
isActorTarget(target)
|
|
789
|
+
? sql`lower(${column}) = lower(${String(value)})`
|
|
790
|
+
: sql`${column} = ${value}`,
|
|
791
|
+
);
|
|
792
|
+
return { sql: or(...comparisons)!, negate: condition === "is-none-of" };
|
|
793
|
+
}
|
|
794
|
+
const value = requireValue(leaf, target, ctx.actorEmail);
|
|
795
|
+
if (condition === "starts-with") {
|
|
796
|
+
return {
|
|
797
|
+
sql: likeSql(column, likePattern(value, "prefix")),
|
|
798
|
+
negate: false,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
if (condition === "ends-with") {
|
|
802
|
+
return {
|
|
803
|
+
sql: likeSql(column, likePattern(value, "suffix")),
|
|
804
|
+
negate: false,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
sql: likeSql(column, likePattern(value, "contains")),
|
|
809
|
+
negate: condition === "not-contains",
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function datePredicate(
|
|
814
|
+
column: SQL,
|
|
815
|
+
target: ResolvedTarget,
|
|
816
|
+
leaf: CrmFilterLeaf,
|
|
817
|
+
ctx: CompileContext,
|
|
818
|
+
): Predicate {
|
|
819
|
+
const condition = leaf.condition;
|
|
820
|
+
if (condition === "between") {
|
|
821
|
+
const [from, to] = requireRange(leaf, target);
|
|
822
|
+
const range = resolveRelativeDateToken(String(from), ctx.now);
|
|
823
|
+
if (range) {
|
|
824
|
+
return {
|
|
825
|
+
sql: and(
|
|
826
|
+
sql`${column} >= ${range.from}`,
|
|
827
|
+
sql`${column} < ${range.to}`,
|
|
828
|
+
)!,
|
|
829
|
+
negate: false,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
sql: and(
|
|
834
|
+
sql`${column} >= ${String(from)}`,
|
|
835
|
+
sql`${column} <= ${String(to)}`,
|
|
836
|
+
)!,
|
|
837
|
+
negate: false,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
const raw = String(requireValue(leaf, target, ctx.actorEmail));
|
|
841
|
+
const range = resolveRelativeDateToken(raw, ctx.now);
|
|
842
|
+
if (range) {
|
|
843
|
+
if (condition === "before") {
|
|
844
|
+
return { sql: sql`${column} < ${range.from}`, negate: false };
|
|
845
|
+
}
|
|
846
|
+
if (condition === "after") {
|
|
847
|
+
return { sql: sql`${column} >= ${range.to}`, negate: false };
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
sql: and(sql`${column} >= ${range.from}`, sql`${column} < ${range.to}`)!,
|
|
851
|
+
negate: false,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
if (condition === "before")
|
|
855
|
+
return { sql: sql`${column} < ${raw}`, negate: false };
|
|
856
|
+
if (condition === "after")
|
|
857
|
+
return { sql: sql`${column} > ${raw}`, negate: false };
|
|
858
|
+
return { sql: sql`${column} = ${raw}`, negate: false };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Correlated current-value rows for one attribute of the outer record. */
|
|
862
|
+
function currentValueRows(attribute: ResolvedAttribute, predicate?: SQL) {
|
|
863
|
+
const conditions = [
|
|
864
|
+
eq(schema.crmRecordFields.recordId, schema.crmRecords.id),
|
|
865
|
+
isNull(schema.crmRecordFields.entryId),
|
|
866
|
+
eq(schema.crmRecordFields.fieldName, attribute.apiSlug),
|
|
867
|
+
isNull(schema.crmRecordFields.activeUntil),
|
|
868
|
+
accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
|
|
869
|
+
];
|
|
870
|
+
if (predicate) conditions.push(predicate);
|
|
871
|
+
return getDb()
|
|
872
|
+
.select({ id: schema.crmRecordFields.id })
|
|
873
|
+
.from(schema.crmRecordFields)
|
|
874
|
+
.where(and(...conditions));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function compileLeaf(leaf: CrmFilterLeaf, ctx: CompileContext): SQL {
|
|
878
|
+
const target = resolveTarget(leaf, ctx.attributes);
|
|
879
|
+
assertConditionSupported(target, leaf.condition);
|
|
880
|
+
|
|
881
|
+
if (target.kind === "column") {
|
|
882
|
+
const predicate = comparePredicate(target.spec.column, target, leaf, ctx);
|
|
883
|
+
return predicate.negate ? sql`not (${predicate.sql})` : predicate.sql;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const column = valueColumnFor(target.attribute);
|
|
887
|
+
const predicate = comparePredicate(column, target, leaf, ctx);
|
|
888
|
+
const rows = currentValueRows(target.attribute, predicate.sql);
|
|
889
|
+
return predicate.negate ? notExists(rows) : exists(rows);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function compileNode(
|
|
893
|
+
node: CrmFilterNode,
|
|
894
|
+
ctx: CompileContext,
|
|
895
|
+
): SQL | undefined {
|
|
896
|
+
const parts = node.conditions.map((entry) =>
|
|
897
|
+
"conditions" in entry ? compileNode(entry, ctx) : compileLeaf(entry, ctx),
|
|
898
|
+
);
|
|
899
|
+
const kept = parts.filter((part): part is SQL => part !== undefined);
|
|
900
|
+
if (kept.length === 0) return undefined;
|
|
901
|
+
return node.op === "or" ? or(...kept)! : and(...kept)!;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export async function compileCrmFilter(input: {
|
|
905
|
+
filter: CrmFilter;
|
|
906
|
+
actorEmail: string | null;
|
|
907
|
+
now?: Date;
|
|
908
|
+
}): Promise<SQL | undefined> {
|
|
909
|
+
const attributes = await resolveAttributes(attributeRefs(input.filter, []));
|
|
910
|
+
return compileNode(input.filter, {
|
|
911
|
+
attributes,
|
|
912
|
+
actorEmail: input.actorEmail,
|
|
913
|
+
now: input.now ?? new Date(),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
// Sorting and keyset pagination
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
interface SortKey {
|
|
922
|
+
expression: SQL;
|
|
923
|
+
direction: "asc" | "desc";
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
interface CursorPayload {
|
|
927
|
+
f: string;
|
|
928
|
+
v: Array<string | number | boolean | null>;
|
|
929
|
+
id: string;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function fingerprint(value: unknown): string {
|
|
933
|
+
const text = JSON.stringify(value) ?? "";
|
|
934
|
+
let hash = 5381;
|
|
935
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
936
|
+
hash = ((hash * 33) ^ text.charCodeAt(index)) >>> 0;
|
|
937
|
+
}
|
|
938
|
+
return hash.toString(36);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function decodeCursor(cursor: string, expected: string): CursorPayload {
|
|
942
|
+
let parsed: unknown;
|
|
943
|
+
try {
|
|
944
|
+
parsed = JSON.parse(cursor) as unknown;
|
|
945
|
+
} catch {
|
|
946
|
+
throw new CrmCursorError("CRM list cursor is not readable.");
|
|
947
|
+
}
|
|
948
|
+
const payload = parsed as Partial<CursorPayload>;
|
|
949
|
+
if (
|
|
950
|
+
!payload ||
|
|
951
|
+
typeof payload.id !== "string" ||
|
|
952
|
+
!Array.isArray(payload.v) ||
|
|
953
|
+
typeof payload.f !== "string"
|
|
954
|
+
) {
|
|
955
|
+
throw new CrmCursorError("CRM list cursor is not readable.");
|
|
956
|
+
}
|
|
957
|
+
if (payload.f !== expected) {
|
|
958
|
+
throw new CrmCursorError(
|
|
959
|
+
"CRM list cursor belongs to a different filter or sort. Restart the list without a cursor.",
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
return payload as CursorPayload;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* "Strictly after the cursor" under NULLS LAST ordering, expanded
|
|
967
|
+
* lexicographically over the sort keys with the record id as the final,
|
|
968
|
+
* always-ascending tiebreak. Without that tiebreak two rows with equal sort
|
|
969
|
+
* values could swap between pages and be skipped or repeated.
|
|
970
|
+
*/
|
|
971
|
+
function keysetPredicate(
|
|
972
|
+
keys: SortKey[],
|
|
973
|
+
cursor: CursorPayload,
|
|
974
|
+
): SQL | undefined {
|
|
975
|
+
let predicate: SQL = sql`${schema.crmRecords.id} > ${cursor.id}`;
|
|
976
|
+
for (let index = keys.length - 1; index >= 0; index -= 1) {
|
|
977
|
+
const key = keys[index]!;
|
|
978
|
+
const value = cursor.v[index] ?? null;
|
|
979
|
+
const equal =
|
|
980
|
+
value === null
|
|
981
|
+
? sql`${key.expression} is null`
|
|
982
|
+
: sql`${key.expression} = ${value}`;
|
|
983
|
+
// NULLs sort last, so nothing follows a null on this key: only the later
|
|
984
|
+
// keys can still order two rows apart.
|
|
985
|
+
const after =
|
|
986
|
+
value === null
|
|
987
|
+
? undefined
|
|
988
|
+
: key.direction === "asc"
|
|
989
|
+
? sql`(${key.expression} is null or ${key.expression} > ${value})`
|
|
990
|
+
: sql`(${key.expression} is null or ${key.expression} < ${value})`;
|
|
991
|
+
const tail = and(equal, predicate)!;
|
|
992
|
+
predicate = after ? or(after, tail)! : tail;
|
|
993
|
+
}
|
|
994
|
+
return predicate;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function orderByFor(keys: SortKey[]): SQL[] {
|
|
998
|
+
const clauses: SQL[] = [];
|
|
999
|
+
for (const key of keys) {
|
|
1000
|
+
// Explicit null rank: SQLite orders NULLs first, PostgreSQL orders them
|
|
1001
|
+
// last. Neither default is portable, so rank them by hand.
|
|
1002
|
+
clauses.push(
|
|
1003
|
+
sql`case when ${key.expression} is null then 1 else 0 end asc`,
|
|
1004
|
+
);
|
|
1005
|
+
clauses.push(
|
|
1006
|
+
key.direction === "asc"
|
|
1007
|
+
? sql`${key.expression} asc`
|
|
1008
|
+
: sql`${key.expression} desc`,
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
clauses.push(sql`${schema.crmRecords.id} asc`);
|
|
1012
|
+
return clauses;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// Saved views
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
export interface StoredCrmView {
|
|
1020
|
+
id: string;
|
|
1021
|
+
name: string;
|
|
1022
|
+
description: string;
|
|
1023
|
+
kind: string | null;
|
|
1024
|
+
viewKind: "table" | "board";
|
|
1025
|
+
targetKind: "object" | "list";
|
|
1026
|
+
targetId: string | null;
|
|
1027
|
+
groupByAttributeId: string | null;
|
|
1028
|
+
filter: CrmFilter;
|
|
1029
|
+
sort: CrmSort;
|
|
1030
|
+
columns: Array<{ attributeId: string; width?: number; hidden?: boolean }>;
|
|
1031
|
+
dataProgramId: string | null;
|
|
1032
|
+
pinned: boolean;
|
|
1033
|
+
audience: "personal" | "shared";
|
|
1034
|
+
updatedAt: string;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Views saved before typed filters stored `{query, fieldEquals}`. Translate them
|
|
1039
|
+
* at the boundary so the rest of the pipeline only ever sees one filter shape.
|
|
1040
|
+
*/
|
|
1041
|
+
export function normalizeStoredFilter(raw: unknown): CrmFilter {
|
|
1042
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1043
|
+
return { op: "and", conditions: [] };
|
|
1044
|
+
}
|
|
1045
|
+
const value = raw as Record<string, unknown>;
|
|
1046
|
+
if (Array.isArray(value.conditions)) {
|
|
1047
|
+
const parsed = crmFilterSchema.safeParse(value);
|
|
1048
|
+
if (!parsed.success) {
|
|
1049
|
+
throw new CrmFilterError(
|
|
1050
|
+
"crm-filter-stored",
|
|
1051
|
+
`Saved view filter is not readable: ${parsed.error.issues[0]?.message ?? "invalid"}.`,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
return parsed.data;
|
|
1055
|
+
}
|
|
1056
|
+
const conditions: CrmFilterLeaf[] = [];
|
|
1057
|
+
if (typeof value.query === "string" && value.query.trim()) {
|
|
1058
|
+
conditions.push({
|
|
1059
|
+
field: "displayName",
|
|
1060
|
+
condition: "contains",
|
|
1061
|
+
value: value.query.trim(),
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
const fieldEquals = value.fieldEquals;
|
|
1065
|
+
if (
|
|
1066
|
+
fieldEquals &&
|
|
1067
|
+
typeof fieldEquals === "object" &&
|
|
1068
|
+
!Array.isArray(fieldEquals)
|
|
1069
|
+
) {
|
|
1070
|
+
for (const [name, entry] of Object.entries(
|
|
1071
|
+
fieldEquals as Record<string, unknown>,
|
|
1072
|
+
)) {
|
|
1073
|
+
if (
|
|
1074
|
+
typeof entry !== "string" &&
|
|
1075
|
+
typeof entry !== "number" &&
|
|
1076
|
+
typeof entry !== "boolean"
|
|
1077
|
+
) {
|
|
1078
|
+
throw new CrmFilterError(
|
|
1079
|
+
"crm-filter-stored",
|
|
1080
|
+
`Saved view filter on "${name}" is not a comparable value.`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
conditions.push({ attributeId: name, condition: "is", value: entry });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const unknownKeys = Object.keys(value).filter(
|
|
1087
|
+
(key) => key !== "query" && key !== "fieldEquals" && key !== "op",
|
|
1088
|
+
);
|
|
1089
|
+
if (unknownKeys.length > 0) {
|
|
1090
|
+
throw new CrmFilterError(
|
|
1091
|
+
"crm-filter-stored",
|
|
1092
|
+
`Saved view filter has unsupported keys: ${unknownKeys.join(", ")}.`,
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
return { op: "and", conditions };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
export function normalizeStoredColumns(
|
|
1099
|
+
raw: unknown,
|
|
1100
|
+
): Array<{ attributeId: string; width?: number; hidden?: boolean }> {
|
|
1101
|
+
if (!Array.isArray(raw)) return [];
|
|
1102
|
+
return raw.flatMap((entry) => {
|
|
1103
|
+
if (typeof entry === "string") return [{ attributeId: entry }];
|
|
1104
|
+
if (!entry || typeof entry !== "object") return [];
|
|
1105
|
+
const value = entry as Record<string, unknown>;
|
|
1106
|
+
const attributeId = value.attributeId;
|
|
1107
|
+
if (typeof attributeId !== "string" || !attributeId) return [];
|
|
1108
|
+
return [
|
|
1109
|
+
{
|
|
1110
|
+
attributeId,
|
|
1111
|
+
...(typeof value.width === "number" ? { width: value.width } : {}),
|
|
1112
|
+
...(typeof value.hidden === "boolean" ? { hidden: value.hidden } : {}),
|
|
1113
|
+
},
|
|
1114
|
+
];
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function parseJson(value: string, label: string): unknown {
|
|
1119
|
+
try {
|
|
1120
|
+
return JSON.parse(value) as unknown;
|
|
1121
|
+
} catch {
|
|
1122
|
+
throw new CrmFilterError(
|
|
1123
|
+
"crm-filter-stored",
|
|
1124
|
+
`Saved view ${label} is not valid JSON.`,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export async function loadCrmSavedView(viewId: string): Promise<StoredCrmView> {
|
|
1130
|
+
const [row] = await getDb()
|
|
1131
|
+
.select()
|
|
1132
|
+
.from(schema.crmSavedViews)
|
|
1133
|
+
.where(
|
|
1134
|
+
and(
|
|
1135
|
+
eq(schema.crmSavedViews.id, viewId),
|
|
1136
|
+
accessFilter(schema.crmSavedViews, schema.crmSavedViewShares),
|
|
1137
|
+
),
|
|
1138
|
+
)
|
|
1139
|
+
.limit(1);
|
|
1140
|
+
if (!row) throw new Error("CRM saved view was not found.");
|
|
1141
|
+
return hydrateSavedViewRow(row);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
export function hydrateSavedViewRow(row: {
|
|
1145
|
+
id: string;
|
|
1146
|
+
name: string;
|
|
1147
|
+
description: string;
|
|
1148
|
+
kind: string | null;
|
|
1149
|
+
viewKind: "table" | "board";
|
|
1150
|
+
targetKind: "object" | "list";
|
|
1151
|
+
targetId: string | null;
|
|
1152
|
+
groupByAttributeId: string | null;
|
|
1153
|
+
filtersJson: string;
|
|
1154
|
+
columnsJson: string;
|
|
1155
|
+
sortJson: string;
|
|
1156
|
+
dataProgramId: string | null;
|
|
1157
|
+
pinned: boolean;
|
|
1158
|
+
visibility: string;
|
|
1159
|
+
updatedAt: string;
|
|
1160
|
+
}): StoredCrmView {
|
|
1161
|
+
const sort = crmSortSchema.safeParse(
|
|
1162
|
+
normalizeStoredSort(parseJson(row.sortJson, "sort")),
|
|
1163
|
+
);
|
|
1164
|
+
if (!sort.success) {
|
|
1165
|
+
throw new CrmFilterError(
|
|
1166
|
+
"crm-filter-stored",
|
|
1167
|
+
`Saved view sort is not readable: ${sort.error.issues[0]?.message ?? "invalid"}.`,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
return {
|
|
1171
|
+
id: row.id,
|
|
1172
|
+
name: row.name,
|
|
1173
|
+
description: row.description,
|
|
1174
|
+
kind: row.kind,
|
|
1175
|
+
viewKind: row.viewKind,
|
|
1176
|
+
targetKind: row.targetKind,
|
|
1177
|
+
targetId: row.targetId,
|
|
1178
|
+
groupByAttributeId: row.groupByAttributeId,
|
|
1179
|
+
filter: normalizeStoredFilter(parseJson(row.filtersJson, "filters")),
|
|
1180
|
+
sort: sort.data,
|
|
1181
|
+
columns: normalizeStoredColumns(parseJson(row.columnsJson, "columns")),
|
|
1182
|
+
dataProgramId: row.dataProgramId,
|
|
1183
|
+
pinned: row.pinned,
|
|
1184
|
+
audience: row.visibility === "private" ? "personal" : "shared",
|
|
1185
|
+
updatedAt: row.updatedAt,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Legacy sort entries used `{field}` for record columns; that still parses. */
|
|
1190
|
+
function normalizeStoredSort(raw: unknown): unknown {
|
|
1191
|
+
return Array.isArray(raw) ? raw : [];
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
// The query
|
|
1196
|
+
// ---------------------------------------------------------------------------
|
|
1197
|
+
|
|
1198
|
+
type ScopeValidationTarget = {
|
|
1199
|
+
connectionId: string;
|
|
1200
|
+
workspaceConnectionId: string | null;
|
|
1201
|
+
provider: string;
|
|
1202
|
+
objectType: string;
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
export type CrmScopeResolver = (
|
|
1206
|
+
target: ScopeValidationTarget,
|
|
1207
|
+
) => Promise<CrmAccessScope | null>;
|
|
1208
|
+
|
|
1209
|
+
async function defaultScopeResolver(
|
|
1210
|
+
target: ScopeValidationTarget,
|
|
1211
|
+
): Promise<CrmAccessScope | null> {
|
|
1212
|
+
if (target.provider === "native") {
|
|
1213
|
+
return resolveNativeCrmAccessScope({
|
|
1214
|
+
connectionId: target.connectionId,
|
|
1215
|
+
objectType: target.objectType,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
if (
|
|
1219
|
+
!isConnectedCrmProvider(target.provider) ||
|
|
1220
|
+
!target.workspaceConnectionId
|
|
1221
|
+
) {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
const adapter = await createConnectedCrmAdapter({
|
|
1225
|
+
provider: target.provider,
|
|
1226
|
+
connectionId: target.workspaceConnectionId,
|
|
1227
|
+
});
|
|
1228
|
+
return adapter.getAccessScope(target.objectType);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const SUMMARY_COLUMNS = new Set([
|
|
1232
|
+
"displayName",
|
|
1233
|
+
"primaryEmail",
|
|
1234
|
+
"domain",
|
|
1235
|
+
"stage",
|
|
1236
|
+
"ownerName",
|
|
1237
|
+
"nextContactAt",
|
|
1238
|
+
"remoteUpdatedAt",
|
|
1239
|
+
"updatedAt",
|
|
1240
|
+
]);
|
|
1241
|
+
|
|
1242
|
+
interface SummaryRow {
|
|
1243
|
+
id: string;
|
|
1244
|
+
displayName: string;
|
|
1245
|
+
kind: string;
|
|
1246
|
+
primaryEmail: string | null;
|
|
1247
|
+
domain: string | null;
|
|
1248
|
+
stage: string | null;
|
|
1249
|
+
ownerName: string | null;
|
|
1250
|
+
nextContactAt: string | null;
|
|
1251
|
+
remoteUpdatedAt: string | null;
|
|
1252
|
+
updatedAt: string;
|
|
1253
|
+
remoteRevision: string | null;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function toRecordSummary(row: SummaryRow, columns?: string[]) {
|
|
1257
|
+
const included = columns?.length ? new Set(columns) : SUMMARY_COLUMNS;
|
|
1258
|
+
return {
|
|
1259
|
+
id: row.id,
|
|
1260
|
+
displayName: row.displayName,
|
|
1261
|
+
kind: row.kind,
|
|
1262
|
+
// Not a display column: it is the concurrency token every write needs, so
|
|
1263
|
+
// a view's column selection must never drop it.
|
|
1264
|
+
remoteRevision: row.remoteRevision,
|
|
1265
|
+
...(included.has("domain") || included.has("primaryEmail")
|
|
1266
|
+
? { subtitle: row.domain ?? row.primaryEmail ?? undefined }
|
|
1267
|
+
: {}),
|
|
1268
|
+
...(included.has("ownerName") ? { owner: row.ownerName ?? undefined } : {}),
|
|
1269
|
+
...(included.has("stage") ? { stage: row.stage ?? undefined } : {}),
|
|
1270
|
+
...(included.has("nextContactAt")
|
|
1271
|
+
? { nextStep: row.nextContactAt ?? undefined }
|
|
1272
|
+
: {}),
|
|
1273
|
+
...(included.has("remoteUpdatedAt") || included.has("updatedAt")
|
|
1274
|
+
? { updatedAt: row.remoteUpdatedAt ?? row.updatedAt }
|
|
1275
|
+
: {}),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export interface QueryCrmRecordsInput {
|
|
1280
|
+
kind?: CrmObjectKind;
|
|
1281
|
+
connectionId?: string;
|
|
1282
|
+
/** Display-name substring; sugar for a `displayName contains` condition. */
|
|
1283
|
+
query?: string;
|
|
1284
|
+
viewId?: string;
|
|
1285
|
+
filter?: CrmFilter;
|
|
1286
|
+
sort?: CrmSort;
|
|
1287
|
+
limit: number;
|
|
1288
|
+
cursor?: string;
|
|
1289
|
+
includeTotal?: boolean;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
export interface QueryCrmRecordsOptions {
|
|
1293
|
+
resolveScope?: CrmScopeResolver;
|
|
1294
|
+
actorEmail?: string | null;
|
|
1295
|
+
now?: Date;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
export async function queryCrmRecords(
|
|
1299
|
+
input: QueryCrmRecordsInput,
|
|
1300
|
+
options: QueryCrmRecordsOptions = {},
|
|
1301
|
+
) {
|
|
1302
|
+
const db = getDb();
|
|
1303
|
+
const limit = Math.min(Math.max(input.limit, 1), MAX_RECORD_LIMIT);
|
|
1304
|
+
const now = options.now ?? new Date();
|
|
1305
|
+
|
|
1306
|
+
if (input.viewId && input.filter) {
|
|
1307
|
+
throw new CrmFilterError(
|
|
1308
|
+
"crm-filter-conflict",
|
|
1309
|
+
"Pass either viewId or an inline filter, not both — a saved view owns its filter.",
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const view = input.viewId ? await loadCrmSavedView(input.viewId) : undefined;
|
|
1314
|
+
if (input.kind && view?.kind && input.kind !== view.kind) {
|
|
1315
|
+
throw new CrmFilterError(
|
|
1316
|
+
"crm-filter-conflict",
|
|
1317
|
+
"CRM saved view kind cannot be overridden.",
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const base: CrmFilterNode = view?.filter ??
|
|
1322
|
+
input.filter ?? { op: "and", conditions: [] };
|
|
1323
|
+
// A `query` NARROWS whatever it is combined with. Appending it to an
|
|
1324
|
+
// or-rooted filter would widen the result set instead, so an or-root is
|
|
1325
|
+
// wrapped as a group under a new and-root.
|
|
1326
|
+
const search: CrmFilterLeaf | undefined = input.query
|
|
1327
|
+
? { field: "displayName", condition: "contains", value: input.query }
|
|
1328
|
+
: undefined;
|
|
1329
|
+
const rootFilter: CrmFilterNode = !search
|
|
1330
|
+
? base
|
|
1331
|
+
: base.op === "or" && base.conditions.length > 0
|
|
1332
|
+
? { op: "and", conditions: [base, search] }
|
|
1333
|
+
: { op: "and", conditions: [...base.conditions, search] };
|
|
1334
|
+
|
|
1335
|
+
const sort = view?.sort?.length ? view.sort : (input.sort ?? []);
|
|
1336
|
+
const attributes = await resolveAttributes(attributeRefs(rootFilter, sort));
|
|
1337
|
+
const ctx: CompileContext = {
|
|
1338
|
+
attributes,
|
|
1339
|
+
actorEmail: options.actorEmail ?? null,
|
|
1340
|
+
now,
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
const conditions = [
|
|
1344
|
+
accessFilter(schema.crmRecords, schema.crmRecordShares),
|
|
1345
|
+
accessFilter(schema.crmConnections, schema.crmConnectionShares),
|
|
1346
|
+
eq(schema.crmRecords.tombstone, false),
|
|
1347
|
+
];
|
|
1348
|
+
const kind = (view?.kind as CrmObjectKind | undefined) ?? input.kind;
|
|
1349
|
+
if (kind) conditions.push(eq(schema.crmRecords.kind, kind));
|
|
1350
|
+
if (input.connectionId) {
|
|
1351
|
+
conditions.push(eq(schema.crmRecords.connectionId, input.connectionId));
|
|
1352
|
+
}
|
|
1353
|
+
const compiled = compileNode(rootFilter, ctx);
|
|
1354
|
+
if (compiled) conditions.push(compiled);
|
|
1355
|
+
|
|
1356
|
+
const keys: SortKey[] = sort.map((entry) => ({
|
|
1357
|
+
expression: sortKeyExpression(entry, attributes),
|
|
1358
|
+
direction: entry.direction,
|
|
1359
|
+
}));
|
|
1360
|
+
|
|
1361
|
+
const shape = fingerprint({
|
|
1362
|
+
kind: kind ?? null,
|
|
1363
|
+
connectionId: input.connectionId ?? null,
|
|
1364
|
+
viewId: input.viewId ?? null,
|
|
1365
|
+
filter: rootFilter,
|
|
1366
|
+
sort,
|
|
1367
|
+
actor: options.actorEmail ?? null,
|
|
1368
|
+
});
|
|
1369
|
+
const pageConditions = [...conditions];
|
|
1370
|
+
if (input.cursor) {
|
|
1371
|
+
const cursor = decodeCursor(input.cursor, shape);
|
|
1372
|
+
const predicate = keysetPredicate(keys, cursor);
|
|
1373
|
+
if (predicate) pageConditions.push(predicate);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const selection: Record<string, unknown> = {
|
|
1377
|
+
id: schema.crmRecords.id,
|
|
1378
|
+
displayName: schema.crmRecords.displayName,
|
|
1379
|
+
kind: schema.crmRecords.kind,
|
|
1380
|
+
primaryEmail: schema.crmRecords.primaryEmail,
|
|
1381
|
+
domain: schema.crmRecords.domain,
|
|
1382
|
+
stage: schema.crmRecords.stage,
|
|
1383
|
+
ownerName: schema.crmRecords.ownerName,
|
|
1384
|
+
nextContactAt: schema.crmRecords.nextContactAt,
|
|
1385
|
+
remoteUpdatedAt: schema.crmRecords.remoteUpdatedAt,
|
|
1386
|
+
updatedAt: schema.crmRecords.updatedAt,
|
|
1387
|
+
remoteRevision: schema.crmRecords.remoteRevision,
|
|
1388
|
+
connectionId: schema.crmRecords.connectionId,
|
|
1389
|
+
objectType: schema.crmRecords.objectType,
|
|
1390
|
+
provider: schema.crmRecords.provider,
|
|
1391
|
+
accessScopeJson: schema.crmRecords.accessScopeJson,
|
|
1392
|
+
workspaceConnectionId: schema.crmConnections.workspaceConnectionId,
|
|
1393
|
+
};
|
|
1394
|
+
keys.forEach((key, index) => {
|
|
1395
|
+
selection[`sortKey${index}`] = key.expression.as(`sort_key_${index}`);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
const rows = (await db
|
|
1399
|
+
.select(selection as never)
|
|
1400
|
+
.from(schema.crmRecords)
|
|
1401
|
+
.innerJoin(
|
|
1402
|
+
schema.crmConnections,
|
|
1403
|
+
eq(schema.crmRecords.connectionId, schema.crmConnections.id),
|
|
1404
|
+
)
|
|
1405
|
+
.where(and(...pageConditions))
|
|
1406
|
+
.orderBy(...orderByFor(keys))
|
|
1407
|
+
.limit(limit + 1)) as unknown as Array<
|
|
1408
|
+
SummaryRow & {
|
|
1409
|
+
connectionId: string;
|
|
1410
|
+
objectType: string;
|
|
1411
|
+
provider: string;
|
|
1412
|
+
accessScopeJson: string;
|
|
1413
|
+
workspaceConnectionId: string | null;
|
|
1414
|
+
} & Record<string, unknown>
|
|
1415
|
+
>;
|
|
1416
|
+
|
|
1417
|
+
const hasMore = rows.length > limit;
|
|
1418
|
+
const pageRows = rows.slice(0, limit);
|
|
1419
|
+
const last = pageRows[pageRows.length - 1];
|
|
1420
|
+
const nextCursor =
|
|
1421
|
+
hasMore && last
|
|
1422
|
+
? JSON.stringify({
|
|
1423
|
+
f: shape,
|
|
1424
|
+
v: keys.map((_, index) =>
|
|
1425
|
+
normalizeCursorValue(last[`sortKey${index}`]),
|
|
1426
|
+
),
|
|
1427
|
+
id: last.id,
|
|
1428
|
+
} satisfies CursorPayload)
|
|
1429
|
+
: undefined;
|
|
1430
|
+
|
|
1431
|
+
const resolveScope = options.resolveScope ?? defaultScopeResolver;
|
|
1432
|
+
const scopeTargets = Array.from(
|
|
1433
|
+
new Map(
|
|
1434
|
+
pageRows.map((row) => [
|
|
1435
|
+
`${row.connectionId}:${row.objectType}`,
|
|
1436
|
+
{
|
|
1437
|
+
connectionId: row.connectionId,
|
|
1438
|
+
workspaceConnectionId: row.workspaceConnectionId,
|
|
1439
|
+
provider: row.provider,
|
|
1440
|
+
objectType: row.objectType,
|
|
1441
|
+
},
|
|
1442
|
+
]),
|
|
1443
|
+
).values(),
|
|
1444
|
+
).slice(0, MAX_SCOPE_VALIDATIONS);
|
|
1445
|
+
const currentScopes = new Map(
|
|
1446
|
+
await Promise.all(
|
|
1447
|
+
scopeTargets.map(
|
|
1448
|
+
async (target) =>
|
|
1449
|
+
[
|
|
1450
|
+
`${target.connectionId}:${target.objectType}`,
|
|
1451
|
+
await resolveScope(target).catch(() => null),
|
|
1452
|
+
] as const,
|
|
1453
|
+
),
|
|
1454
|
+
),
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
const columnNames = view?.columns.map((column) => column.attributeId);
|
|
1458
|
+
const records = pageRows
|
|
1459
|
+
.filter((row) => {
|
|
1460
|
+
const current = currentScopes.get(
|
|
1461
|
+
`${row.connectionId}:${row.objectType}`,
|
|
1462
|
+
);
|
|
1463
|
+
return Boolean(
|
|
1464
|
+
current &&
|
|
1465
|
+
scopesAreCompatible(parseCrmAccessScope(row.accessScopeJson), current),
|
|
1466
|
+
);
|
|
1467
|
+
})
|
|
1468
|
+
.map((row) => toRecordSummary(row, columnNames));
|
|
1469
|
+
|
|
1470
|
+
let totalEstimate: number | undefined;
|
|
1471
|
+
if (input.includeTotal) {
|
|
1472
|
+
const [count] = await db
|
|
1473
|
+
.select({ total: sql<number>`count(*)` })
|
|
1474
|
+
.from(schema.crmRecords)
|
|
1475
|
+
.innerJoin(
|
|
1476
|
+
schema.crmConnections,
|
|
1477
|
+
eq(schema.crmRecords.connectionId, schema.crmConnections.id),
|
|
1478
|
+
)
|
|
1479
|
+
.where(and(...conditions));
|
|
1480
|
+
totalEstimate = Number(count?.total ?? 0);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
return {
|
|
1484
|
+
records,
|
|
1485
|
+
nextCursor,
|
|
1486
|
+
complete: !hasMore,
|
|
1487
|
+
...(totalEstimate === undefined ? {} : { totalEstimate }),
|
|
1488
|
+
...(view
|
|
1489
|
+
? {
|
|
1490
|
+
appliedView: {
|
|
1491
|
+
id: view.id,
|
|
1492
|
+
name: view.name,
|
|
1493
|
+
viewKind: view.viewKind,
|
|
1494
|
+
...(view.kind ? { kind: view.kind } : {}),
|
|
1495
|
+
...(view.dataProgramId
|
|
1496
|
+
? { dataProgramId: view.dataProgramId }
|
|
1497
|
+
: {}),
|
|
1498
|
+
...(view.groupByAttributeId
|
|
1499
|
+
? { groupByAttributeId: view.groupByAttributeId }
|
|
1500
|
+
: {}),
|
|
1501
|
+
filter: view.filter,
|
|
1502
|
+
sort: view.sort,
|
|
1503
|
+
columns: view.columns,
|
|
1504
|
+
},
|
|
1505
|
+
}
|
|
1506
|
+
: {}),
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function normalizeCursorValue(
|
|
1511
|
+
value: unknown,
|
|
1512
|
+
): string | number | boolean | null {
|
|
1513
|
+
if (value === null || value === undefined) return null;
|
|
1514
|
+
if (
|
|
1515
|
+
typeof value === "string" ||
|
|
1516
|
+
typeof value === "number" ||
|
|
1517
|
+
typeof value === "boolean"
|
|
1518
|
+
) {
|
|
1519
|
+
return value;
|
|
1520
|
+
}
|
|
1521
|
+
// A sort key that is not a comparable scalar cannot be resumed from; failing
|
|
1522
|
+
// here beats emitting a cursor that silently reorders the next page.
|
|
1523
|
+
throw new CrmCursorError(
|
|
1524
|
+
"CRM list sort key is not a comparable value; remove it from the sort.",
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function sortKeyExpression(
|
|
1529
|
+
entry: CrmSort[number],
|
|
1530
|
+
attributes: Map<string, ResolvedAttribute>,
|
|
1531
|
+
): SQL {
|
|
1532
|
+
const target = resolveTarget(entry, attributes);
|
|
1533
|
+
if (target.kind === "column") return target.spec.column;
|
|
1534
|
+
if (target.attribute.multi) {
|
|
1535
|
+
throw new CrmFilterError(
|
|
1536
|
+
"crm-filter-sort",
|
|
1537
|
+
`"${target.label}" is multi-valued and has no single sortable value.`,
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
const column = valueColumnFor(target.attribute);
|
|
1541
|
+
// A record has at most one current row per attribute (the partial unique
|
|
1542
|
+
// index enforces it); LIMIT 1 keeps PostgreSQL from erroring if that ever
|
|
1543
|
+
// slips rather than failing the whole page.
|
|
1544
|
+
const subquery = getDb()
|
|
1545
|
+
.select({ value: sql`${column}`.as("value") })
|
|
1546
|
+
.from(schema.crmRecordFields)
|
|
1547
|
+
.where(
|
|
1548
|
+
and(
|
|
1549
|
+
eq(schema.crmRecordFields.recordId, schema.crmRecords.id),
|
|
1550
|
+
isNull(schema.crmRecordFields.entryId),
|
|
1551
|
+
eq(schema.crmRecordFields.fieldName, target.attribute.apiSlug),
|
|
1552
|
+
isNull(schema.crmRecordFields.activeUntil),
|
|
1553
|
+
accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
|
|
1554
|
+
),
|
|
1555
|
+
)
|
|
1556
|
+
.limit(1);
|
|
1557
|
+
return sql`${subquery}`;
|
|
1558
|
+
}
|