@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,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
|
+
}
|