@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,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one path a CRM `status` attribute value may be changed through.
|
|
3
|
+
*
|
|
4
|
+
* A status is not just another typed value: stages drive SLAs, boards, pipeline
|
|
5
|
+
* reporting, and celebration, so a blind status write is how a record silently
|
|
6
|
+
* re-enters a stage it already left, or lands in a retired one that no board
|
|
7
|
+
* column renders. The rules below are therefore stated as NAMED SETS with the
|
|
8
|
+
* reason each one exists, and every blocked case answers with a sentence a
|
|
9
|
+
* person can act on rather than a boolean the caller has to re-interpret.
|
|
10
|
+
*
|
|
11
|
+
* The allowed values come from the attribute's `crm_attribute_options` rows, not
|
|
12
|
+
* from an enum in this file — a workspace renames and retires its own stages.
|
|
13
|
+
*
|
|
14
|
+
* Two properties are load-bearing and easy to lose in a refactor:
|
|
15
|
+
*
|
|
16
|
+
* 1. Bulk transitions PARTITION by eligibility. An ineligible target is
|
|
17
|
+
* reported, never quietly written and never quietly dropped.
|
|
18
|
+
* 2. The observed `from` values are re-asserted in the claim query's WHERE
|
|
19
|
+
* clause, so a status that moved between the partition read and the write is
|
|
20
|
+
* left alone instead of being clobbered by a decision made about stale state.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
24
|
+
import { and, eq, inArray, isNull, or, type SQL } from "drizzle-orm";
|
|
25
|
+
|
|
26
|
+
import type { CrmActorType } from "../../shared/crm-contract.js";
|
|
27
|
+
import { schema } from "../db/index.js";
|
|
28
|
+
import {
|
|
29
|
+
CrmAttributeValueError,
|
|
30
|
+
writeCrmRecordField,
|
|
31
|
+
type CrmFieldWriteDb,
|
|
32
|
+
type CrmWritableAttribute,
|
|
33
|
+
} from "./record-fields.js";
|
|
34
|
+
|
|
35
|
+
/** Most targets one bulk transition may carry. */
|
|
36
|
+
export const MAX_STATUS_TRANSITION_TARGETS = 200;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A lifecycle problem the caller must fix before retrying. Shares the attribute
|
|
40
|
+
* writer's 422 contract so every "your input is wrong" failure out of the CRM
|
|
41
|
+
* write path looks the same to an HTTP, MCP, or agent caller.
|
|
42
|
+
*/
|
|
43
|
+
export class CrmLifecycleError extends CrmAttributeValueError {
|
|
44
|
+
constructor(code: string, message: string) {
|
|
45
|
+
super(code, message);
|
|
46
|
+
this.name = "CrmLifecycleError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CrmStatusOption {
|
|
51
|
+
value: string;
|
|
52
|
+
title: string;
|
|
53
|
+
position: number;
|
|
54
|
+
archived: boolean;
|
|
55
|
+
celebrate: boolean;
|
|
56
|
+
targetDays: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CrmStatusAttribute extends CrmWritableAttribute {
|
|
60
|
+
label: string;
|
|
61
|
+
authority: "provider" | "derived-local" | "local-authoritative";
|
|
62
|
+
archived: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CrmLifecycle {
|
|
66
|
+
attribute: CrmStatusAttribute;
|
|
67
|
+
/** Every declared option, board order first. */
|
|
68
|
+
options: CrmStatusOption[];
|
|
69
|
+
/** Options a value may move INTO. */
|
|
70
|
+
enterableValues: string[];
|
|
71
|
+
/** Every declared value, archived included — what a stored value may be. */
|
|
72
|
+
knownValues: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Why one target was not transitioned. Stable codes; sentences are for humans. */
|
|
76
|
+
export type CrmStatusBlockCode =
|
|
77
|
+
| "attribute-archived"
|
|
78
|
+
| "provider-authority"
|
|
79
|
+
| "record-tombstoned"
|
|
80
|
+
| "unknown-status"
|
|
81
|
+
| "archived-status"
|
|
82
|
+
| "concurrent-transition";
|
|
83
|
+
|
|
84
|
+
export interface CrmStatusBlock {
|
|
85
|
+
code: CrmStatusBlockCode;
|
|
86
|
+
message: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Loading
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load one status attribute and its options.
|
|
95
|
+
*
|
|
96
|
+
* A non-`status` attribute is a caller bug, not a blocked transition: the
|
|
97
|
+
* lifecycle governs stages, and routing an ordinary text field through it would
|
|
98
|
+
* silently gain rules nothing declared.
|
|
99
|
+
*/
|
|
100
|
+
export async function loadCrmStatusLifecycle(
|
|
101
|
+
db: CrmFieldWriteDb,
|
|
102
|
+
attributeId: string,
|
|
103
|
+
): Promise<CrmLifecycle> {
|
|
104
|
+
const [row] = await db
|
|
105
|
+
.select({
|
|
106
|
+
id: schema.crmFieldPolicies.id,
|
|
107
|
+
apiSlug: schema.crmFieldPolicies.apiSlug,
|
|
108
|
+
fieldName: schema.crmFieldPolicies.fieldName,
|
|
109
|
+
label: schema.crmFieldPolicies.label,
|
|
110
|
+
attributeType: schema.crmFieldPolicies.attributeType,
|
|
111
|
+
multi: schema.crmFieldPolicies.multi,
|
|
112
|
+
historyTracked: schema.crmFieldPolicies.historyTracked,
|
|
113
|
+
valueType: schema.crmFieldPolicies.valueType,
|
|
114
|
+
storagePolicy: schema.crmFieldPolicies.storagePolicy,
|
|
115
|
+
authority: schema.crmFieldPolicies.authority,
|
|
116
|
+
archived: schema.crmFieldPolicies.archived,
|
|
117
|
+
})
|
|
118
|
+
.from(schema.crmFieldPolicies)
|
|
119
|
+
.where(
|
|
120
|
+
and(
|
|
121
|
+
eq(schema.crmFieldPolicies.id, attributeId),
|
|
122
|
+
accessFilter(schema.crmFieldPolicies, schema.crmFieldPolicyShares),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
.limit(1);
|
|
126
|
+
|
|
127
|
+
if (!row) {
|
|
128
|
+
throw new CrmLifecycleError(
|
|
129
|
+
"crm-status-attribute-not-found",
|
|
130
|
+
`CRM attribute "${attributeId}" was not found or you cannot see it.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (row.attributeType !== "status") {
|
|
134
|
+
throw new CrmLifecycleError(
|
|
135
|
+
"crm-status-attribute-type",
|
|
136
|
+
`Attribute "${row.apiSlug ?? row.fieldName}" is a ${row.attributeType} attribute. Only a status attribute has a lifecycle.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
row.storagePolicy !== "mirrored" &&
|
|
141
|
+
row.storagePolicy !== "derived-local" &&
|
|
142
|
+
row.storagePolicy !== "local-authoritative"
|
|
143
|
+
) {
|
|
144
|
+
throw new CrmLifecycleError(
|
|
145
|
+
"crm-status-attribute-not-writable",
|
|
146
|
+
`Attribute "${row.apiSlug ?? row.fieldName}" has storage policy "${row.storagePolicy}" and holds no local value to transition.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const optionRows = await db
|
|
151
|
+
.select({
|
|
152
|
+
value: schema.crmAttributeOptions.value,
|
|
153
|
+
title: schema.crmAttributeOptions.title,
|
|
154
|
+
position: schema.crmAttributeOptions.position,
|
|
155
|
+
archived: schema.crmAttributeOptions.archived,
|
|
156
|
+
celebrate: schema.crmAttributeOptions.celebrate,
|
|
157
|
+
targetDays: schema.crmAttributeOptions.targetDays,
|
|
158
|
+
})
|
|
159
|
+
.from(schema.crmAttributeOptions)
|
|
160
|
+
.where(
|
|
161
|
+
and(
|
|
162
|
+
eq(schema.crmAttributeOptions.attributeId, row.id),
|
|
163
|
+
accessFilter(
|
|
164
|
+
schema.crmAttributeOptions,
|
|
165
|
+
schema.crmAttributeOptionShares,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const options = optionRows
|
|
171
|
+
.map((option) => ({
|
|
172
|
+
value: option.value,
|
|
173
|
+
title: option.title,
|
|
174
|
+
position: option.position,
|
|
175
|
+
archived: option.archived,
|
|
176
|
+
celebrate: option.celebrate,
|
|
177
|
+
targetDays: option.targetDays,
|
|
178
|
+
}))
|
|
179
|
+
.sort((a, b) => a.position - b.position || a.value.localeCompare(b.value));
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
attribute: {
|
|
183
|
+
id: row.id,
|
|
184
|
+
apiSlug: row.apiSlug ?? row.fieldName,
|
|
185
|
+
attributeType: "status",
|
|
186
|
+
multi: row.multi,
|
|
187
|
+
historyTracked: row.historyTracked,
|
|
188
|
+
valueType: row.valueType,
|
|
189
|
+
storagePolicy: row.storagePolicy,
|
|
190
|
+
fieldPolicyId: row.id,
|
|
191
|
+
label: row.label,
|
|
192
|
+
authority: row.authority,
|
|
193
|
+
archived: row.archived,
|
|
194
|
+
},
|
|
195
|
+
options,
|
|
196
|
+
// ENTERABLE, not "valid": an archived stage keeps every row already parked
|
|
197
|
+
// there (which is why leaving one is always allowed) but accepts nothing new.
|
|
198
|
+
enterableValues: options
|
|
199
|
+
.filter((option) => !option.archived)
|
|
200
|
+
.map((option) => option.value),
|
|
201
|
+
knownValues: options.map((option) => option.value),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Rules
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
function quoteList(values: readonly string[]): string {
|
|
210
|
+
return values.length ? values.join(", ") : "(none defined)";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The reason this transition cannot happen, or `null` when it can.
|
|
215
|
+
*
|
|
216
|
+
* `from === to` is deliberately allowed: a repeat write is idempotent, and the
|
|
217
|
+
* bitemporal writer turns it into no write at all, so a bulk "move these to
|
|
218
|
+
* Won" over a set that already contains Won rows reports them as unchanged
|
|
219
|
+
* rather than blocked. Leaving ANY status — including an unknown or archived
|
|
220
|
+
* one — is allowed for the same reason a retired stage still holds rows: the
|
|
221
|
+
* way out of a bad state must not itself be blocked.
|
|
222
|
+
*/
|
|
223
|
+
export function crmStatusBlockReason(
|
|
224
|
+
lifecycle: CrmLifecycle,
|
|
225
|
+
input: { from: string | null; to: string; recordTombstoned?: boolean },
|
|
226
|
+
): CrmStatusBlock | null {
|
|
227
|
+
const label = lifecycle.attribute.label;
|
|
228
|
+
|
|
229
|
+
if (lifecycle.attribute.archived) {
|
|
230
|
+
return {
|
|
231
|
+
code: "attribute-archived",
|
|
232
|
+
message: `Cannot set "${label}" — the attribute is archived. Unarchive it before moving anything through it.`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (lifecycle.attribute.authority === "provider") {
|
|
236
|
+
return {
|
|
237
|
+
code: "provider-authority",
|
|
238
|
+
message: `Cannot set "${label}" locally — the connected provider owns this attribute. Prepare a change and complete it in the provider instead.`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (input.recordTombstoned) {
|
|
242
|
+
return {
|
|
243
|
+
code: "record-tombstoned",
|
|
244
|
+
message: `Cannot move a merged or deleted record through "${label}".`,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (!lifecycle.knownValues.includes(input.to)) {
|
|
248
|
+
return {
|
|
249
|
+
code: "unknown-status",
|
|
250
|
+
message: `"${input.to}" is not a value of "${label}". Known values: ${quoteList(lifecycle.knownValues)}. Add the option before moving anything into it.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (!lifecycle.enterableValues.includes(input.to)) {
|
|
254
|
+
return {
|
|
255
|
+
code: "archived-status",
|
|
256
|
+
message: `Cannot move into "${input.to}" — that value of "${label}" is archived. Pick one of: ${quoteList(lifecycle.enterableValues)}.`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Reading current values
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export interface CrmStatusTarget {
|
|
267
|
+
recordId: string;
|
|
268
|
+
/** Set for a LIST-ENTRY status; omitted or null for a record status. */
|
|
269
|
+
entryId?: string | null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface CurrentStatus {
|
|
273
|
+
fieldId: string;
|
|
274
|
+
value: string | null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function targetKey(target: CrmStatusTarget): string {
|
|
278
|
+
return target.entryId
|
|
279
|
+
? `entry:${target.entryId}`
|
|
280
|
+
: `record:${target.recordId}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Current status value per target. A target with no row yet maps to `null`,
|
|
285
|
+
* which is a real state (never set) and distinct from a target that is missing
|
|
286
|
+
* from the map entirely (not visible to this caller).
|
|
287
|
+
*/
|
|
288
|
+
async function readCurrentStatuses(
|
|
289
|
+
db: CrmFieldWriteDb,
|
|
290
|
+
lifecycle: CrmLifecycle,
|
|
291
|
+
targets: CrmStatusTarget[],
|
|
292
|
+
extraWhere?: SQL,
|
|
293
|
+
): Promise<Map<string, CurrentStatus>> {
|
|
294
|
+
const entryIds = targets
|
|
295
|
+
.map((target) => target.entryId)
|
|
296
|
+
.filter((entryId): entryId is string => Boolean(entryId));
|
|
297
|
+
const recordIds = targets
|
|
298
|
+
.filter((target) => !target.entryId)
|
|
299
|
+
.map((target) => target.recordId);
|
|
300
|
+
|
|
301
|
+
const shapes: SQL[] = [];
|
|
302
|
+
if (entryIds.length) {
|
|
303
|
+
shapes.push(inArray(schema.crmRecordFields.entryId, entryIds) as SQL);
|
|
304
|
+
}
|
|
305
|
+
if (recordIds.length) {
|
|
306
|
+
shapes.push(
|
|
307
|
+
and(
|
|
308
|
+
inArray(schema.crmRecordFields.recordId, recordIds),
|
|
309
|
+
isNull(schema.crmRecordFields.entryId),
|
|
310
|
+
) as SQL,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (!shapes.length) return new Map();
|
|
314
|
+
|
|
315
|
+
const rows = await db
|
|
316
|
+
.select({
|
|
317
|
+
id: schema.crmRecordFields.id,
|
|
318
|
+
recordId: schema.crmRecordFields.recordId,
|
|
319
|
+
entryId: schema.crmRecordFields.entryId,
|
|
320
|
+
stringValue: schema.crmRecordFields.stringValue,
|
|
321
|
+
})
|
|
322
|
+
.from(schema.crmRecordFields)
|
|
323
|
+
.where(
|
|
324
|
+
and(
|
|
325
|
+
or(...shapes),
|
|
326
|
+
eq(schema.crmRecordFields.fieldName, lifecycle.attribute.apiSlug),
|
|
327
|
+
isNull(schema.crmRecordFields.activeUntil),
|
|
328
|
+
...(extraWhere ? [extraWhere] : []),
|
|
329
|
+
accessFilter(schema.crmRecordFields, schema.crmRecordFieldShares),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const byTarget = new Map<string, CurrentStatus>();
|
|
334
|
+
for (const row of rows) {
|
|
335
|
+
byTarget.set(targetKey({ recordId: row.recordId, entryId: row.entryId }), {
|
|
336
|
+
fieldId: row.id,
|
|
337
|
+
value: row.stringValue,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return byTarget;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Re-read the targets whose status still equals what the partition observed.
|
|
345
|
+
*
|
|
346
|
+
* This is the concurrency guard: the observed `from` values go into the WHERE
|
|
347
|
+
* clause, so a target somebody else moved in between does not come back and is
|
|
348
|
+
* therefore never written with a decision made about its old state.
|
|
349
|
+
*
|
|
350
|
+
* ponytail: inside one transaction this is exact on SQLite/libSQL, which
|
|
351
|
+
* serializes writers. On Postgres read-committed a writer could still commit
|
|
352
|
+
* between this SELECT and the write; move to `SELECT … FOR UPDATE` behind a
|
|
353
|
+
* dialect helper in core if a hosted deployment shows clobbered stage history.
|
|
354
|
+
*/
|
|
355
|
+
export async function claimCrmStatusTransition(input: {
|
|
356
|
+
db: CrmFieldWriteDb;
|
|
357
|
+
lifecycle: CrmLifecycle;
|
|
358
|
+
/** Targets to claim, each with the status value the decision was based on. */
|
|
359
|
+
expected: Array<{ target: CrmStatusTarget; from: string | null }>;
|
|
360
|
+
}): Promise<Set<string>> {
|
|
361
|
+
const withValue = input.expected.filter((entry) => entry.from !== null);
|
|
362
|
+
const withoutValue = input.expected.filter((entry) => entry.from === null);
|
|
363
|
+
|
|
364
|
+
const claimed = new Set<string>();
|
|
365
|
+
|
|
366
|
+
if (withValue.length) {
|
|
367
|
+
const observed = await readCurrentStatuses(
|
|
368
|
+
input.db,
|
|
369
|
+
input.lifecycle,
|
|
370
|
+
withValue.map((entry) => entry.target),
|
|
371
|
+
inArray(schema.crmRecordFields.stringValue, [
|
|
372
|
+
...new Set(withValue.map((entry) => entry.from as string)),
|
|
373
|
+
]) as SQL,
|
|
374
|
+
);
|
|
375
|
+
for (const entry of withValue) {
|
|
376
|
+
const key = targetKey(entry.target);
|
|
377
|
+
if (observed.get(key)?.value === entry.from) claimed.add(key);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (withoutValue.length) {
|
|
382
|
+
// "No row" cannot be asserted by matching a value, so it is asserted by its
|
|
383
|
+
// absence: a target that has grown a status row since the partition read is
|
|
384
|
+
// not claimed.
|
|
385
|
+
const observed = await readCurrentStatuses(
|
|
386
|
+
input.db,
|
|
387
|
+
input.lifecycle,
|
|
388
|
+
withoutValue.map((entry) => entry.target),
|
|
389
|
+
);
|
|
390
|
+
for (const entry of withoutValue) {
|
|
391
|
+
const key = targetKey(entry.target);
|
|
392
|
+
if (!observed.has(key)) claimed.add(key);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return claimed;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Bulk transition
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
export type CrmStatusOutcome = "changed" | "unchanged" | "skipped";
|
|
404
|
+
|
|
405
|
+
export interface CrmStatusTransitionRow {
|
|
406
|
+
recordId: string;
|
|
407
|
+
entryId: string | null;
|
|
408
|
+
from: string | null;
|
|
409
|
+
to: string;
|
|
410
|
+
outcome: CrmStatusOutcome;
|
|
411
|
+
/** Present on every `skipped` row; absent otherwise. */
|
|
412
|
+
block?: CrmStatusBlock;
|
|
413
|
+
/** Present on every `changed` row: how the bitemporal writer stored it. */
|
|
414
|
+
mode?: "insert" | "close-and-insert" | "update-in-place";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface CrmStatusTransitionReport {
|
|
418
|
+
attributeId: string;
|
|
419
|
+
apiSlug: string;
|
|
420
|
+
to: string;
|
|
421
|
+
changed: number;
|
|
422
|
+
unchanged: number;
|
|
423
|
+
skipped: number;
|
|
424
|
+
/** Skipped counts keyed by the status they were still in — GTM's shape. */
|
|
425
|
+
skippedByStatus: Record<string, number>;
|
|
426
|
+
skippedByReason: Record<CrmStatusBlockCode, number>;
|
|
427
|
+
rows: CrmStatusTransitionRow[];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface CrmStatusTransitionInput {
|
|
431
|
+
db: CrmFieldWriteDb;
|
|
432
|
+
lifecycle: CrmLifecycle;
|
|
433
|
+
targets: CrmStatusTarget[];
|
|
434
|
+
to: string;
|
|
435
|
+
actor: { type: CrmActorType; id?: string | null };
|
|
436
|
+
ownership: {
|
|
437
|
+
ownerEmail: string;
|
|
438
|
+
orgId: string | null;
|
|
439
|
+
visibility: "private" | "org" | "public";
|
|
440
|
+
};
|
|
441
|
+
/** Record ids known to be tombstoned; those targets are reported, not written. */
|
|
442
|
+
tombstonedRecordIds?: ReadonlySet<string>;
|
|
443
|
+
now?: string;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Move a bounded set of records or list entries into one status.
|
|
448
|
+
*
|
|
449
|
+
* Eligible targets are written through `writeCrmRecordField`, so a real move
|
|
450
|
+
* closes the previous value's row and opens a new one — that pair IS the
|
|
451
|
+
* time-in-stage history. Ineligible targets come back in `rows` with the
|
|
452
|
+
* sentence explaining why; nothing is written for them and nothing is dropped.
|
|
453
|
+
*/
|
|
454
|
+
export async function applyCrmStatusTransitions(
|
|
455
|
+
input: CrmStatusTransitionInput,
|
|
456
|
+
): Promise<CrmStatusTransitionReport> {
|
|
457
|
+
if (input.targets.length > MAX_STATUS_TRANSITION_TARGETS) {
|
|
458
|
+
throw new CrmLifecycleError(
|
|
459
|
+
"crm-status-transition-too-many",
|
|
460
|
+
`A status transition may cover at most ${MAX_STATUS_TRANSITION_TARGETS} targets; received ${input.targets.length}.`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const now = input.now ?? new Date().toISOString();
|
|
465
|
+
const tombstoned = input.tombstonedRecordIds ?? new Set<string>();
|
|
466
|
+
const current = await readCurrentStatuses(
|
|
467
|
+
input.db,
|
|
468
|
+
input.lifecycle,
|
|
469
|
+
input.targets,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const rows: CrmStatusTransitionRow[] = [];
|
|
473
|
+
const eligible: Array<{ target: CrmStatusTarget; from: string | null }> = [];
|
|
474
|
+
|
|
475
|
+
for (const target of input.targets) {
|
|
476
|
+
const from = current.get(targetKey(target))?.value ?? null;
|
|
477
|
+
const block = crmStatusBlockReason(input.lifecycle, {
|
|
478
|
+
from,
|
|
479
|
+
to: input.to,
|
|
480
|
+
recordTombstoned: tombstoned.has(target.recordId),
|
|
481
|
+
});
|
|
482
|
+
if (block) {
|
|
483
|
+
rows.push({
|
|
484
|
+
recordId: target.recordId,
|
|
485
|
+
entryId: target.entryId ?? null,
|
|
486
|
+
from,
|
|
487
|
+
to: input.to,
|
|
488
|
+
outcome: "skipped",
|
|
489
|
+
block,
|
|
490
|
+
});
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
eligible.push({ target, from });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const claimed = eligible.length
|
|
497
|
+
? await claimCrmStatusTransition({
|
|
498
|
+
db: input.db,
|
|
499
|
+
lifecycle: input.lifecycle,
|
|
500
|
+
expected: eligible,
|
|
501
|
+
})
|
|
502
|
+
: new Set<string>();
|
|
503
|
+
|
|
504
|
+
for (const entry of eligible) {
|
|
505
|
+
const key = targetKey(entry.target);
|
|
506
|
+
if (!claimed.has(key)) {
|
|
507
|
+
rows.push({
|
|
508
|
+
recordId: entry.target.recordId,
|
|
509
|
+
entryId: entry.target.entryId ?? null,
|
|
510
|
+
from: entry.from,
|
|
511
|
+
to: input.to,
|
|
512
|
+
outcome: "skipped",
|
|
513
|
+
block: {
|
|
514
|
+
code: "concurrent-transition",
|
|
515
|
+
message: `"${input.lifecycle.attribute.label}" changed from "${entry.from ?? "(not set)"}" while this move was being prepared, so it was left alone. Re-read it and decide again.`,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const result = await writeCrmRecordField({
|
|
522
|
+
db: input.db,
|
|
523
|
+
target: {
|
|
524
|
+
recordId: entry.target.recordId,
|
|
525
|
+
entryId: entry.target.entryId ?? null,
|
|
526
|
+
},
|
|
527
|
+
attribute: input.lifecycle.attribute,
|
|
528
|
+
value: input.to,
|
|
529
|
+
actor: input.actor,
|
|
530
|
+
ownership: input.ownership,
|
|
531
|
+
now,
|
|
532
|
+
});
|
|
533
|
+
rows.push({
|
|
534
|
+
recordId: entry.target.recordId,
|
|
535
|
+
entryId: entry.target.entryId ?? null,
|
|
536
|
+
from: entry.from,
|
|
537
|
+
to: input.to,
|
|
538
|
+
outcome: result.changed ? "changed" : "unchanged",
|
|
539
|
+
...(result.changed ? { mode: result.mode } : {}),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const skippedByStatus: Record<string, number> = {};
|
|
544
|
+
const skippedByReason = {} as Record<CrmStatusBlockCode, number>;
|
|
545
|
+
for (const row of rows) {
|
|
546
|
+
if (row.outcome !== "skipped" || !row.block) continue;
|
|
547
|
+
const status = row.from ?? "(not set)";
|
|
548
|
+
skippedByStatus[status] = (skippedByStatus[status] ?? 0) + 1;
|
|
549
|
+
skippedByReason[row.block.code] =
|
|
550
|
+
(skippedByReason[row.block.code] ?? 0) + 1;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
attributeId: input.lifecycle.attribute.id,
|
|
555
|
+
apiSlug: input.lifecycle.attribute.apiSlug,
|
|
556
|
+
to: input.to,
|
|
557
|
+
changed: rows.filter((row) => row.outcome === "changed").length,
|
|
558
|
+
unchanged: rows.filter((row) => row.outcome === "unchanged").length,
|
|
559
|
+
skipped: rows.filter((row) => row.outcome === "skipped").length,
|
|
560
|
+
skippedByStatus,
|
|
561
|
+
skippedByReason,
|
|
562
|
+
rows,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// Single-target entry points
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Move one record or list entry into a status.
|
|
572
|
+
*
|
|
573
|
+
* A single-target caller has nowhere to put a partition report, so a blocked
|
|
574
|
+
* target is raised as the sentence it must act on rather than returned as a row
|
|
575
|
+
* nobody reads — including the concurrency block, whose whole point is that the
|
|
576
|
+
* caller re-reads and decides again.
|
|
577
|
+
*/
|
|
578
|
+
export async function applyOneCrmStatusTransition(
|
|
579
|
+
input: Omit<CrmStatusTransitionInput, "targets"> & {
|
|
580
|
+
target: CrmStatusTarget;
|
|
581
|
+
},
|
|
582
|
+
): Promise<{
|
|
583
|
+
changed: boolean;
|
|
584
|
+
mode?: "insert" | "close-and-insert" | "update-in-place";
|
|
585
|
+
}> {
|
|
586
|
+
const { target, ...rest } = input;
|
|
587
|
+
const report = await applyCrmStatusTransitions({
|
|
588
|
+
...rest,
|
|
589
|
+
targets: [target],
|
|
590
|
+
});
|
|
591
|
+
const [row] = report.rows;
|
|
592
|
+
if (!row) {
|
|
593
|
+
throw new CrmLifecycleError(
|
|
594
|
+
"crm-status-transition-unreported",
|
|
595
|
+
`"${input.lifecycle.attribute.label}" was neither transitioned nor blocked for ${
|
|
596
|
+
target.entryId ? `entry ${target.entryId}` : `record ${target.recordId}`
|
|
597
|
+
}. Nothing was written; report this rather than retrying.`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (row.block) throw new CrmLifecycleError(row.block.code, row.block.message);
|
|
601
|
+
return {
|
|
602
|
+
changed: row.outcome === "changed",
|
|
603
|
+
...(row.mode ? { mode: row.mode } : {}),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Throw the reason this target cannot enter `to`, or return the value it would
|
|
609
|
+
* move from.
|
|
610
|
+
*
|
|
611
|
+
* The gate half, for a caller that owns its own atomic write and its own
|
|
612
|
+
* concurrency check: `update-crm-record` is one revision-checked mutation with
|
|
613
|
+
* an audit row covering the whole patch, so routing its status fields into a
|
|
614
|
+
* second writer would leave that row claiming fields it did not write.
|
|
615
|
+
*/
|
|
616
|
+
export async function assertCrmStatusTransitionAllowed(input: {
|
|
617
|
+
db: CrmFieldWriteDb;
|
|
618
|
+
lifecycle: CrmLifecycle;
|
|
619
|
+
target: CrmStatusTarget;
|
|
620
|
+
to: string;
|
|
621
|
+
recordTombstoned?: boolean;
|
|
622
|
+
}): Promise<{ from: string | null }> {
|
|
623
|
+
const current = await readCurrentStatuses(input.db, input.lifecycle, [
|
|
624
|
+
input.target,
|
|
625
|
+
]);
|
|
626
|
+
const from = current.get(targetKey(input.target))?.value ?? null;
|
|
627
|
+
const block = crmStatusBlockReason(input.lifecycle, {
|
|
628
|
+
from,
|
|
629
|
+
to: input.to,
|
|
630
|
+
recordTombstoned: input.recordTombstoned,
|
|
631
|
+
});
|
|
632
|
+
if (block) throw new CrmLifecycleError(block.code, block.message);
|
|
633
|
+
return { from };
|
|
634
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProviderApiRuntime,
|
|
3
|
+
type ProviderApiCredentialResolver,
|
|
4
|
+
type ProviderApiId,
|
|
5
|
+
} from "@agent-native/core/provider-api";
|
|
6
|
+
import { getCredentialContext } from "@agent-native/core/server";
|
|
7
|
+
import { resolveWorkspaceConnectionCredentialForApp } from "@agent-native/core/workspace-connections";
|
|
8
|
+
|
|
9
|
+
export const CRM_APP_ID = "crm";
|
|
10
|
+
export const CRM_PROVIDER_API_IDS = ["hubspot", "salesforce"] as [
|
|
11
|
+
ProviderApiId,
|
|
12
|
+
ProviderApiId,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const resolveCrmProviderCredential: ProviderApiCredentialResolver = async ({
|
|
16
|
+
provider,
|
|
17
|
+
key,
|
|
18
|
+
ctx,
|
|
19
|
+
workspaceProvider,
|
|
20
|
+
connectionId,
|
|
21
|
+
}) => {
|
|
22
|
+
const resolvedProvider = workspaceProvider ?? provider;
|
|
23
|
+
const credential = await resolveWorkspaceConnectionCredentialForApp({
|
|
24
|
+
appId: CRM_APP_ID,
|
|
25
|
+
provider: resolvedProvider,
|
|
26
|
+
key,
|
|
27
|
+
...(connectionId ? { connectionId } : {}),
|
|
28
|
+
userEmail: ctx.userEmail,
|
|
29
|
+
orgId: ctx.orgId ?? null,
|
|
30
|
+
});
|
|
31
|
+
if (!credential.available || !credential.value || !credential.provenance) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
key: credential.provenance.resolvedKey,
|
|
37
|
+
value: credential.value,
|
|
38
|
+
source: "workspace_connection",
|
|
39
|
+
provider: resolvedProvider,
|
|
40
|
+
connectionId: credential.provenance.connectionId,
|
|
41
|
+
connectionLabel: credential.provenance.connectionLabel,
|
|
42
|
+
scope: credential.provenance.secretScope,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const runtime = createProviderApiRuntime({
|
|
47
|
+
appId: CRM_APP_ID,
|
|
48
|
+
providerIds: CRM_PROVIDER_API_IDS,
|
|
49
|
+
localCredentialSource: "crm_workspace_connection",
|
|
50
|
+
getCredentialContext: () => {
|
|
51
|
+
const context = getCredentialContext();
|
|
52
|
+
if (!context) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"CRM provider API requests require an authenticated request context.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
},
|
|
59
|
+
resolveCredential: resolveCrmProviderCredential,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export function getCrmProviderApiRuntime() {
|
|
63
|
+
return runtime;
|
|
64
|
+
}
|