@elevasis/sdk 1.40.0 → 1.41.0

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.
@@ -1,151 +1,151 @@
1
1
  <!-- @generated by packages/sdk/scripts/copy-reference-docs.mjs -- DO NOT EDIT -->
2
2
  <!-- Regenerate: pnpm scaffold:sync -->
3
3
 
4
- ---
5
- title: Customize CRM Actions
6
- description: Add, hide, or replace CRM deal action buttons in a template-derived project, and override default platform action workflows with project-owned implementations.
7
- ---
8
-
9
- # Customize CRM Actions
10
-
11
- CRM deal pages derive their action buttons from an `ActionDef[]` array. The shared UI reads that array from the provider, filters it with `deriveActions(deal, actions)`, and renders one-click buttons for actions without a `payloadSchema`.
12
-
4
+ ---
5
+ title: Customize CRM Actions
6
+ description: Add, hide, or replace CRM deal action buttons in a template-derived project, and override default platform action workflows with project-owned implementations.
7
+ ---
8
+
9
+ # Customize CRM Actions
10
+
11
+ CRM deal pages derive their action buttons from an `ActionDef[]` array. The shared UI reads that array from the provider, filters it with `deriveActions(deal, actions)`, and renders one-click buttons for actions without a `payloadSchema`.
12
+
13
13
  For the broader CRM extension map -- pages, sidebars, hooks, workflow adapters, System Interfaces, and org-model boundaries -- start with [Build and Extend CRM](extend-crm.md). This recipe is only the deal-action path.
14
-
15
- **Shape reference:** The `ActionDef` flat shape and the consolidation that replaced the old `handler`/`kind` union are documented in `apps/docs/content/docs/in-progress/active-development/sdk-changes/crm/crm-current-state-assessment.mdx`.
16
-
17
- Use this recipe when a user asks for work like:
18
-
19
- - "Hide Close Lost from the deal drawer."
20
- - "Add a Send Quote action to deals in Proposal."
21
- - "Override Move to Proposal to also create a task in our project tool."
22
- - "Build a custom deal page that runs one of our workflows."
23
-
24
- ## How Platform Action Dispatch Works
25
-
14
+
15
+ **Shape reference:** The `ActionDef` flat shape and the consolidation that replaced the old `handler`/`kind` union are documented in `apps/docs/content/docs/in-progress/active-development/sdk-changes/crm/crm-current-state-assessment.mdx`.
16
+
17
+ Use this recipe when a user asks for work like:
18
+
19
+ - "Hide Close Lost from the deal drawer."
20
+ - "Add a Send Quote action to deals in Proposal."
21
+ - "Override Move to Proposal to also create a task in our project tool."
22
+ - "Build a custom deal page that runs one of our workflows."
23
+
24
+ ## How Platform Action Dispatch Works
25
+
26
26
  Every action in the CRM action catalog maps to a deployed workflow via its `workflowId` field. When the shared UI calls `POST /deals/:dealId/actions/:actionKey`, the platform:
27
-
28
- 1. Validates `isAvailableFor(deal)` server-side (security gate -- cannot be skipped).
29
- 2. Resolves `actionDef.workflowId` to a deployed resource.
30
- 3. Dispatches through `SingleExecutionCoordinator` -- same execution engine as every other workflow.
31
- 4. Returns the refetched deal.
32
-
27
+
28
+ 1. Validates `isAvailableFor(deal)` server-side (security gate -- cannot be skipped).
29
+ 2. Resolves `actionDef.workflowId` to a deployed resource.
30
+ 3. Dispatches through `SingleExecutionCoordinator` -- same execution engine as every other workflow.
31
+ 4. Returns the refetched deal.
32
+
33
33
  External projects override an action's behavior by deploying a workflow with the same `workflowId` as the action entry. The resource registry picks the project-owned workflow over the platform default. The `isAvailableFor` predicate comes from the caller-supplied action catalog; override what the action does and keep button availability aligned with the server-side gate.
34
34
 
35
35
  CRM action dispatch is an API-backed surface. The tenant Organization Model must expose an active and ready `sales.crm.apiInterface` marker before shared CRM action buttons are considered ready. If a lead-gen workflow hands off into CRM, model that crossing through lead-gen resources that bind the CRM pipeline plus a scoped topology grant to `sales.crm.apiInterface` instead of making base lead-gen readiness depend on CRM catalogs.
36
-
37
- ## ActionDef Shape
38
-
39
- Read the generated contract reference before editing:
40
-
41
- `node_modules/@elevasis/sdk/reference/scaffold/reference/contracts.md`
42
-
43
- The current flat shape (no `handler` nesting, no `kind` discriminator):
44
-
45
- ```ts
46
- export interface ActionDef {
47
- key: string
48
- label: string
49
- isAvailableFor: (deal: AcqDealRow) => boolean
50
- workflowId: string
51
- payloadSchema?: z.ZodTypeAny
52
- }
53
- ```
54
-
55
- `deriveActions(deal, actions)` returns only render-time actions: `{ key, label, payloadSchema? }`. It does not expose `workflowId` or `isAvailableFor` to the browser.
56
-
57
- A minimal entry looks like:
58
-
59
- ```ts
60
- {
61
- key: 'move_to_proposal',
62
- label: 'Move to Proposal',
63
- isAvailableFor: (deal) => deal.stage_key === 'interested',
64
- workflowId: 'move_to_proposal-workflow',
65
- payloadSchema: undefined
66
- }
67
- ```
68
-
69
- ## acqDb and crm Helpers
70
-
71
- Workflows backing CRM actions use worker adapters instead of raw Supabase calls. The acquisition adapter is imported as `acqDb` from `@elevasis/sdk/worker` and exposes the action-workflow helpers:
72
-
73
- - `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating state_key.
74
- - `acqDb.recordDealActivity({ dealId, type, title, description?, payload? })` -- appends an entry to the deal's `activity_log` JSONB column. Use this for outbound/inbound audit and lifecycle events.
75
- - `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.
76
-
77
- These wrap existing `LeadService` logic -- they are extraction, not new business logic. The separate `crm` adapter also exposes focused CRM methods such as `crm.recordActivity(...)`; use `acqDb` for action workflows that need deal transitions and the broader acquisition substrate.
78
-
79
- ## Override a Default Action
80
-
81
- Deploy a workflow with the same `workflowId` as the default action you want to replace. The resource registry resolves your project-owned workflow first.
82
-
83
- The canonical transition workflow (see `packages/elevasis-operations/src/sales/crm/actions/move-to-proposal.ts` for the deployed reference):
84
-
85
- ```ts
86
- // operations/src/sales/crm/actions/move-to-proposal.ts
87
- import type { WorkflowDefinition } from '@elevasis/sdk'
88
- import { acqDb } from '@elevasis/sdk/worker'
89
- import { resourceDescriptors } from '@core/config/organization-model'
90
- import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
91
-
92
- export const moveToProposalWorkflow: WorkflowDefinition = {
93
- config: {
94
- resource: resourceDescriptors.moveToProposal,
95
- resourceId: resourceDescriptors.moveToProposal.id,
96
- name: 'Move to Proposal',
97
- type: resourceDescriptors.moveToProposal.kind,
98
- version: '1.0.0',
99
- status: 'prod',
100
- category: 'internal',
101
- },
102
- contract: {
103
- inputSchema: ActionWorkflowInputSchema,
104
- outputSchema: ActionWorkflowOutputSchema
105
- },
106
- steps: {
107
- transition: {
108
- id: 'transition',
109
- name: 'Transition to Proposal',
110
- inputSchema: ActionWorkflowInputSchema,
111
- outputSchema: ActionWorkflowOutputSchema,
112
- handler: async (rawInput, context) => {
113
- const { dealId } = rawInput as { dealId: string; organizationId: string }
114
- context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
115
- await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
116
- return { dealId, sent: false }
117
- },
118
- next: null
119
- }
120
- },
121
- entryPoint: 'transition'
122
- }
123
- ```
124
-
36
+
37
+ ## ActionDef Shape
38
+
39
+ Read the generated contract reference before editing:
40
+
41
+ `operations/node_modules/@elevasis/sdk/reference/scaffold/reference/contracts.md`
42
+
43
+ The current flat shape (no `handler` nesting, no `kind` discriminator):
44
+
45
+ ```ts
46
+ export interface ActionDef {
47
+ key: string
48
+ label: string
49
+ isAvailableFor: (deal: AcqDealRow) => boolean
50
+ workflowId: string
51
+ payloadSchema?: z.ZodTypeAny
52
+ }
53
+ ```
54
+
55
+ `deriveActions(deal, actions)` returns only render-time actions: `{ key, label, payloadSchema? }`. It does not expose `workflowId` or `isAvailableFor` to the browser.
56
+
57
+ A minimal entry looks like:
58
+
59
+ ```ts
60
+ {
61
+ key: 'move_to_proposal',
62
+ label: 'Move to Proposal',
63
+ isAvailableFor: (deal) => deal.stage_key === 'interested',
64
+ workflowId: 'move_to_proposal-workflow',
65
+ payloadSchema: undefined
66
+ }
67
+ ```
68
+
69
+ ## acqDb and crm Helpers
70
+
71
+ Workflows backing CRM actions use worker adapters instead of raw Supabase calls. The acquisition adapter is imported as `acqDb` from `@elevasis/sdk/worker` and exposes the action-workflow helpers:
72
+
73
+ - `acqDb.transitionDeal({ dealId, toStage, toState? })` -- moves a deal to a new stage, optionally updating state_key.
74
+ - `acqDb.recordDealActivity({ dealId, type, title, description?, payload? })` -- appends an entry to the deal's `activity_log` JSONB column. Use this for outbound/inbound audit and lifecycle events.
75
+ - `acqDb.loadDeal({ dealId })` -- returns the deal row joined with contact and company.
76
+
77
+ These wrap existing `LeadService` logic -- they are extraction, not new business logic. The separate `crm` adapter also exposes focused CRM methods such as `crm.recordActivity(...)`; use `acqDb` for action workflows that need deal transitions and the broader acquisition substrate.
78
+
79
+ ## Override a Default Action
80
+
81
+ Deploy a workflow with the same `workflowId` as the default action you want to replace. The resource registry resolves your project-owned workflow first.
82
+
83
+ The canonical transition workflow (see `packages/elevasis-operations/src/sales/crm/actions/move-to-proposal.ts` for the deployed reference):
84
+
85
+ ```ts
86
+ // operations/src/sales/crm/actions/move-to-proposal.ts
87
+ import type { WorkflowDefinition } from '@elevasis/sdk'
88
+ import { acqDb } from '@elevasis/sdk/worker'
89
+ import { resourceDescriptors } from '@core/config/organization-model'
90
+ import { ActionWorkflowInputSchema, ActionWorkflowOutputSchema } from '../shared/action-workflow-schemas.js'
91
+
92
+ export const moveToProposalWorkflow: WorkflowDefinition = {
93
+ config: {
94
+ resource: resourceDescriptors.moveToProposal,
95
+ resourceId: resourceDescriptors.moveToProposal.id,
96
+ name: 'Move to Proposal',
97
+ type: resourceDescriptors.moveToProposal.kind,
98
+ version: '1.0.0',
99
+ status: 'prod',
100
+ category: 'internal',
101
+ },
102
+ contract: {
103
+ inputSchema: ActionWorkflowInputSchema,
104
+ outputSchema: ActionWorkflowOutputSchema
105
+ },
106
+ steps: {
107
+ transition: {
108
+ id: 'transition',
109
+ name: 'Transition to Proposal',
110
+ inputSchema: ActionWorkflowInputSchema,
111
+ outputSchema: ActionWorkflowOutputSchema,
112
+ handler: async (rawInput, context) => {
113
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
114
+ context.logger.info(`[transition] Moving deal ${dealId} to proposal`)
115
+ await acqDb.transitionDeal({ dealId, toStage: 'proposal' })
116
+ return { dealId, sent: false }
117
+ },
118
+ next: null
119
+ }
120
+ },
121
+ entryPoint: 'transition'
122
+ }
123
+ ```
124
+
125
125
  To add side effects (create a task, send a Slack message, log a deal activity), extend the handler before the `return`. The OM descriptor ID must match the action entry's `workflowId` exactly.
126
-
127
- Register the workflow in the operations manifest:
128
-
129
- ```ts
130
- // operations/src/index.ts
131
- import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
132
-
133
- export const deploymentSpec = {
134
- workflows: [moveToProposalWorkflow],
135
- agents: []
136
- }
137
- ```
138
-
139
- Then deploy:
140
-
141
- ```bash
142
- pnpm exec elevasis-sdk deploy
143
- ```
144
-
145
- ## 1. Override the Shared CRM Action Set (UI-Side)
146
-
147
- To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
148
-
126
+
127
+ Register the workflow in the operations manifest:
128
+
129
+ ```ts
130
+ // operations/src/index.ts
131
+ import { moveToProposalWorkflow } from './sales/crm/actions/move-to-proposal.js'
132
+
133
+ export const deploymentSpec = {
134
+ workflows: [moveToProposalWorkflow],
135
+ agents: []
136
+ }
137
+ ```
138
+
139
+ Then deploy:
140
+
141
+ ```bash
142
+ pnpm exec elevasis-sdk deploy
143
+ ```
144
+
145
+ ## 1. Override the Shared CRM Action Set (UI-Side)
146
+
147
+ To hide, reorder, or relabel buttons in the shared deal UI, create a local action config module:
148
+
149
149
  ```ts
150
150
  // ui/src/config/crm-actions.ts
151
151
  import type { ActionDef } from '@elevasis/sdk'
@@ -153,9 +153,9 @@ import { platformCrmActions } from './platform-crm-actions'
153
153
 
154
154
  export const crmActions: ActionDef[] = platformCrmActions.filter((action) => action.key !== 'move_to_closed_lost')
155
155
  ```
156
-
157
- To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
158
-
156
+
157
+ To add a new action entry alongside the defaults (note: brand-new keys are not server-dispatched until the platform knows their `workflowId`):
158
+
159
159
  ```ts
160
160
  // ui/src/config/crm-actions.ts
161
161
  import type { ActionDef } from '@elevasis/sdk'
@@ -163,204 +163,204 @@ import { platformCrmActions } from './platform-crm-actions'
163
163
 
164
164
  export const crmActions: ActionDef[] = [
165
165
  ...platformCrmActions,
166
- {
167
- key: 'send_quote',
168
- label: 'Send Quote',
169
- isAvailableFor: (deal) => deal.stage_key === 'proposal',
170
- workflowId: 'send-quote-workflow'
171
- }
172
- ]
173
- ```
174
-
175
- Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
176
-
177
- ```tsx
178
- // ui/src/main.tsx
179
- import { crmActions } from './config/crm-actions'
180
-
181
- const App = createElevasisApp({
182
- router,
183
- apiUrl: API_URL,
184
- auth: {
185
- clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
186
- redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
187
- devMode: true
188
- },
189
- theme: { presets: themePresets, background, loader },
190
- queryClient,
191
- crmActions
192
- })
193
- ```
194
-
195
- If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:
196
-
197
- ```tsx
198
- <ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
199
- <AppRoutes />
200
- </ElevasisUIProvider>
201
- ```
202
-
203
- This controls the shared `DealDetailPage` and `DealDrawer` action row.
204
-
205
- ## 2. Add a Custom Workflow-Backed Action
206
-
207
- For a brand-new action key that calls a project-owned workflow, define the workflow first.
208
-
209
- If the action sends email, writes to another channel, or otherwise touches a customer, use `acqDb.recordDealActivity` inside the workflow handler to append an audit entry to the deal's `activity_log`. For an advanced Instantly-thread-aware variant that prefers in-thread replies and falls back to fresh outbound, see the canonical CRM action examples in `packages/elevasis-operations/src/sales/crm/actions/`.
210
-
211
- ### Define the Workflow Contract
212
-
213
- ```ts
214
- // core/types/index.ts
215
- import { z } from 'zod'
216
-
217
- export const sendQuoteInputSchema = z.object({
218
- dealId: z.string().uuid(),
219
- organizationId: z.string().uuid()
220
- })
221
-
222
- export const sendQuoteOutputSchema = z.object({
223
- dealId: z.string().uuid(),
224
- sent: z.boolean(),
225
- messageId: z.string().optional()
226
- })
227
-
228
- export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
229
- export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
230
- ```
231
-
232
- ### Define the Workflow
233
-
234
- ```ts
235
- // operations/src/sales/send-quote.ts
236
- import type { WorkflowDefinition } from '@elevasis/sdk'
237
- import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
238
- import { resourceDescriptors } from '@core/config/organization-model'
239
- import {
240
- sendQuoteInputSchema,
241
- sendQuoteOutputSchema
242
- } from '@core/types'
243
-
244
- export const sendQuoteWorkflow: WorkflowDefinition = {
245
- config: {
246
- resource: resourceDescriptors.sendQuote,
247
- resourceId: resourceDescriptors.sendQuote.id,
248
- name: 'Send Quote',
249
- type: resourceDescriptors.sendQuote.kind,
250
- version: '1.0.0',
251
- status: 'dev',
252
- category: 'internal',
253
- },
254
- contract: {
255
- inputSchema: sendQuoteInputSchema,
256
- outputSchema: sendQuoteOutputSchema
257
- },
258
- steps: {
259
- send: {
260
- id: 'send',
261
- name: 'Send Quote Email',
262
- inputSchema: sendQuoteInputSchema,
263
- outputSchema: sendQuoteOutputSchema,
264
- handler: async (rawInput, context) => {
265
- const { dealId } = rawInput as { dealId: string; organizationId: string }
266
- const deal = await acqDb.loadDeal({ dealId })
267
-
268
- const resend = createResendAdapter('my-resend-credential')
269
- context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)
270
-
271
- const sent = await resend.sendEmail({
272
- to: deal.contact.email,
273
- subject: 'Your quote is ready',
274
- html: `<p>Your quote is ready. Reply to this email with questions.</p>`
275
- })
276
-
277
- await acqDb.recordDealActivity({
278
- dealId,
279
- type: 'quote_sent',
280
- title: 'Sent quote',
281
- payload: { triggered_by_action: 'send_quote', channel: 'email' }
282
- })
283
-
284
- return {
285
- dealId,
286
- sent: true,
287
- messageId: String(sent.id ?? '')
288
- }
289
- },
290
- next: null
291
- }
292
- },
293
- entryPoint: 'send'
294
- }
295
- ```
296
-
297
- Register the workflow in the operations manifest and deploy:
298
-
299
- ```ts
300
- // operations/src/index.ts
301
- import { sendQuoteWorkflow } from './sales/send-quote.js'
302
-
303
- export const deploymentSpec = {
304
- workflows: [sendQuoteWorkflow],
305
- agents: []
306
- }
307
- ```
308
-
309
- ```bash
310
- pnpm exec elevasis-sdk deploy
311
- ```
312
-
313
- ### Choose the UI Path
314
-
315
- A custom `ActionDef` entry with `workflowId: 'send-quote-workflow'` can be rendered through the shared `crmActions` provider path. Server dispatch through `POST /deals/:dealId/actions/:actionKey` is constrained by the platform-known/default action set in v1, so use a custom deal page or render slot that calls the workflow directly through `/execute` or `/execute-async` when the action key is outside that server-side set.
316
-
317
- ```tsx
318
- // ui/src/features/crm/components/SendQuoteButton.tsx
319
- import { Button } from '@mantine/core'
320
- import { useMutation } from '@tanstack/react-query'
321
- import { useElevasisServices } from '@elevasis/ui/provider'
322
- import { resourceDescriptors } from '@core/config/organization-model'
323
-
324
- export function SendQuoteButton({ dealId }: { dealId: string }) {
325
- const { apiRequest, organizationId } = useElevasisServices()
326
- const resourceId = resourceDescriptors.sendQuote.id
327
-
328
- const sendQuote = useMutation({
329
- mutationFn: async () => {
330
- if (!organizationId) throw new Error('Organization context is not ready')
331
-
332
- return apiRequest('/execute-async', {
333
- method: 'POST',
334
- body: JSON.stringify({
335
- resourceType: 'workflow',
336
- resourceId,
337
- input: { dealId, organizationId }
338
- })
339
- })
340
- }
341
- })
342
-
343
- return (
344
- <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
345
- Send Quote
346
- </Button>
347
- )
348
- }
349
- ```
350
-
166
+ {
167
+ key: 'send_quote',
168
+ label: 'Send Quote',
169
+ isAvailableFor: (deal) => deal.stage_key === 'proposal',
170
+ workflowId: 'send-quote-workflow'
171
+ }
172
+ ]
173
+ ```
174
+
175
+ Wire it into the app provider. If the template uses `createElevasisApp`, pass it in the app config:
176
+
177
+ ```tsx
178
+ // ui/src/main.tsx
179
+ import { crmActions } from './config/crm-actions'
180
+
181
+ const App = createElevasisApp({
182
+ router,
183
+ apiUrl: API_URL,
184
+ auth: {
185
+ clientId: import.meta.env.VITE_WORKOS_CLIENT_ID,
186
+ redirectUri: import.meta.env.VITE_WORKOS_REDIRECT_URI,
187
+ devMode: true
188
+ },
189
+ theme: { presets: themePresets, background, loader },
190
+ queryClient,
191
+ crmActions
192
+ })
193
+ ```
194
+
195
+ If the project hand-wires providers, pass the same array to `ElevasisUIProvider` or `ElevasisCoreProvider`:
196
+
197
+ ```tsx
198
+ <ElevasisUIProvider auth={auth} apiUrl={API_URL} crmActions={crmActions}>
199
+ <AppRoutes />
200
+ </ElevasisUIProvider>
201
+ ```
202
+
203
+ This controls the shared `DealDetailPage` and `DealDrawer` action row.
204
+
205
+ ## 2. Add a Custom Workflow-Backed Action
206
+
207
+ For a brand-new action key that calls a project-owned workflow, define the workflow first.
208
+
209
+ If the action sends email, writes to another channel, or otherwise touches a customer, use `acqDb.recordDealActivity` inside the workflow handler to append an audit entry to the deal's `activity_log`. For an advanced Instantly-thread-aware variant that prefers in-thread replies and falls back to fresh outbound, see the canonical CRM action examples in `packages/elevasis-operations/src/sales/crm/actions/`.
210
+
211
+ ### Define the Workflow Contract
212
+
213
+ ```ts
214
+ // core/types/index.ts
215
+ import { z } from 'zod'
216
+
217
+ export const sendQuoteInputSchema = z.object({
218
+ dealId: z.string().uuid(),
219
+ organizationId: z.string().uuid()
220
+ })
221
+
222
+ export const sendQuoteOutputSchema = z.object({
223
+ dealId: z.string().uuid(),
224
+ sent: z.boolean(),
225
+ messageId: z.string().optional()
226
+ })
227
+
228
+ export type SendQuoteInput = z.infer<typeof sendQuoteInputSchema>
229
+ export type SendQuoteOutput = z.infer<typeof sendQuoteOutputSchema>
230
+ ```
231
+
232
+ ### Define the Workflow
233
+
234
+ ```ts
235
+ // operations/src/sales/send-quote.ts
236
+ import type { WorkflowDefinition } from '@elevasis/sdk'
237
+ import { acqDb, createResendAdapter } from '@elevasis/sdk/worker'
238
+ import { resourceDescriptors } from '@core/config/organization-model'
239
+ import {
240
+ sendQuoteInputSchema,
241
+ sendQuoteOutputSchema
242
+ } from '@core/types'
243
+
244
+ export const sendQuoteWorkflow: WorkflowDefinition = {
245
+ config: {
246
+ resource: resourceDescriptors.sendQuote,
247
+ resourceId: resourceDescriptors.sendQuote.id,
248
+ name: 'Send Quote',
249
+ type: resourceDescriptors.sendQuote.kind,
250
+ version: '1.0.0',
251
+ status: 'dev',
252
+ category: 'internal',
253
+ },
254
+ contract: {
255
+ inputSchema: sendQuoteInputSchema,
256
+ outputSchema: sendQuoteOutputSchema
257
+ },
258
+ steps: {
259
+ send: {
260
+ id: 'send',
261
+ name: 'Send Quote Email',
262
+ inputSchema: sendQuoteInputSchema,
263
+ outputSchema: sendQuoteOutputSchema,
264
+ handler: async (rawInput, context) => {
265
+ const { dealId } = rawInput as { dealId: string; organizationId: string }
266
+ const deal = await acqDb.loadDeal({ dealId })
267
+
268
+ const resend = createResendAdapter('my-resend-credential')
269
+ context.logger.info(`[send-quote] Sending quote for deal ${dealId}`)
270
+
271
+ const sent = await resend.sendEmail({
272
+ to: deal.contact.email,
273
+ subject: 'Your quote is ready',
274
+ html: `<p>Your quote is ready. Reply to this email with questions.</p>`
275
+ })
276
+
277
+ await acqDb.recordDealActivity({
278
+ dealId,
279
+ type: 'quote_sent',
280
+ title: 'Sent quote',
281
+ payload: { triggered_by_action: 'send_quote', channel: 'email' }
282
+ })
283
+
284
+ return {
285
+ dealId,
286
+ sent: true,
287
+ messageId: String(sent.id ?? '')
288
+ }
289
+ },
290
+ next: null
291
+ }
292
+ },
293
+ entryPoint: 'send'
294
+ }
295
+ ```
296
+
297
+ Register the workflow in the operations manifest and deploy:
298
+
299
+ ```ts
300
+ // operations/src/index.ts
301
+ import { sendQuoteWorkflow } from './sales/send-quote.js'
302
+
303
+ export const deploymentSpec = {
304
+ workflows: [sendQuoteWorkflow],
305
+ agents: []
306
+ }
307
+ ```
308
+
309
+ ```bash
310
+ pnpm exec elevasis-sdk deploy
311
+ ```
312
+
313
+ ### Choose the UI Path
314
+
315
+ A custom `ActionDef` entry with `workflowId: 'send-quote-workflow'` can be rendered through the shared `crmActions` provider path. Server dispatch through `POST /deals/:dealId/actions/:actionKey` is constrained by the platform-known/default action set in v1, so use a custom deal page or render slot that calls the workflow directly through `/execute` or `/execute-async` when the action key is outside that server-side set.
316
+
317
+ ```tsx
318
+ // ui/src/features/crm/components/SendQuoteButton.tsx
319
+ import { Button } from '@mantine/core'
320
+ import { useMutation } from '@tanstack/react-query'
321
+ import { useElevasisServices } from '@elevasis/ui/provider'
322
+ import { resourceDescriptors } from '@core/config/organization-model'
323
+
324
+ export function SendQuoteButton({ dealId }: { dealId: string }) {
325
+ const { apiRequest, organizationId } = useElevasisServices()
326
+ const resourceId = resourceDescriptors.sendQuote.id
327
+
328
+ const sendQuote = useMutation({
329
+ mutationFn: async () => {
330
+ if (!organizationId) throw new Error('Organization context is not ready')
331
+
332
+ return apiRequest('/execute-async', {
333
+ method: 'POST',
334
+ body: JSON.stringify({
335
+ resourceType: 'workflow',
336
+ resourceId,
337
+ input: { dealId, organizationId }
338
+ })
339
+ })
340
+ }
341
+ })
342
+
343
+ return (
344
+ <Button loading={sendQuote.isPending} onClick={() => sendQuote.mutate()}>
345
+ Send Quote
346
+ </Button>
347
+ )
348
+ }
349
+ ```
350
+
351
351
  Use this button through a custom deal route or a `renderActions` slot where available.
352
352
 
353
353
  Keep the same interface boundary for direct workflow buttons: custom UI may render read-only deal context without API readiness, but buttons that call `/execute`, `/execute-async`, or CRM action routes should be hidden or disabled until the relevant System Interface and any scoped topology grant are ready.
354
-
355
- ## 3. Build a Fully Custom Deal Page
356
-
357
- When you own the full page, use the primitives directly:
358
-
359
- - `useDealDetail(dealId)` loads the deal.
360
- - `deriveActions(deal, crmActions)` filters the action set.
361
- - `useExecuteAction({ dealId })` dispatches platform-known action keys.
362
- - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
363
-
354
+
355
+ ## 3. Build a Fully Custom Deal Page
356
+
357
+ When you own the full page, use the primitives directly:
358
+
359
+ - `useDealDetail(dealId)` loads the deal.
360
+ - `deriveActions(deal, crmActions)` filters the action set.
361
+ - `useExecuteAction({ dealId })` dispatches platform-known action keys.
362
+ - Project-owned workflow buttons call `/execute` or `/execute-async` directly when they are outside the server-dispatched action set.
363
+
364
364
  ```tsx
365
365
  import { deriveActions } from '@elevasis/sdk'
366
366
  import { Group, Stack } from '@mantine/core'
@@ -368,38 +368,38 @@ import { useMemo } from 'react'
368
368
  import { useDealDetail, useExecuteAction } from '@elevasis/ui/hooks'
369
369
  import { crmActions } from '../config/crm-actions'
370
370
  import { SendQuoteButton } from './SendQuoteButton'
371
-
372
- export function CustomDealPage({ dealId }: { dealId: string }) {
373
- const { data: deal } = useDealDetail(dealId)
371
+
372
+ export function CustomDealPage({ dealId }: { dealId: string }) {
373
+ const { data: deal } = useDealDetail(dealId)
374
374
  const executeAction = useExecuteAction({ dealId })
375
375
 
376
376
  const platformActions = useMemo(() => {
377
377
  return deal ? deriveActions(deal, crmActions) : []
378
378
  }, [deal])
379
-
380
- if (!deal) return null
381
-
382
- return (
383
- <Stack>
384
- <Group>
385
- {platformActions.map((action) => (
386
- <button
387
- key={action.key}
388
- type="button"
389
- onClick={() => executeAction.mutate({ key: action.key })}
390
- >
391
- {action.label}
392
- </button>
393
- ))}
394
- <SendQuoteButton dealId={deal.id} />
395
- </Group>
396
- </Stack>
397
- )
398
- }
399
- ```
400
-
401
- ## CRM State-Key Source of Truth
402
-
379
+
380
+ if (!deal) return null
381
+
382
+ return (
383
+ <Stack>
384
+ <Group>
385
+ {platformActions.map((action) => (
386
+ <button
387
+ key={action.key}
388
+ type="button"
389
+ onClick={() => executeAction.mutate({ key: action.key })}
390
+ >
391
+ {action.label}
392
+ </button>
393
+ ))}
394
+ <SendQuoteButton dealId={deal.id} />
395
+ </Group>
396
+ </Stack>
397
+ )
398
+ }
399
+ ```
400
+
401
+ ## CRM State-Key Source of Truth
402
+
403
403
  CRM `stage_key` and `state_key` values are tenant/runtime data. In the Elevasis workspace, the canonical CRM pipeline definition lives in `@repo/elevasis-core/organization-model` and is authored into the `sales.crm:catalog/crm.pipeline` ontology catalog on the canonical organization model. Published `@elevasis/core` keeps only generic `StatefulPipelineDefinition` types/helpers and transport schemas.
404
404
 
405
405
  Within the Elevasis monorepo, import runtime CRM definitions from `@repo/elevasis-core/organization-model`:
@@ -416,30 +416,30 @@ const validStates = getValidStatesForStage(CRM_PIPELINE_DEFINITION, 'interested'
416
416
  ```
417
417
 
418
418
  Template-derived projects should define their own CRM catalog/action config in project code rather than importing Elevasis runtime constants from published core.
419
-
420
- ## Activity Log Conventions
421
-
422
- The `acq_deal_activity_log` table uses a `kind` field to distinguish how a state transition was initiated. Two values are relevant for CRM state changes:
423
-
424
- - `state_change` -- written by workflow steps (e.g. `crm-send-booking-link.ts` transitioning to `discovery_link_sent`). Carries an `action_taken` payload identifying the workflow.
425
- - `state_changed_manually` -- written by the `PATCH /api/deals/:dealId/state` route when an operator edits `state_key` directly from the UI. Carries the user id and the before/after state. This is a pure column update with no workflow side-effects.
426
-
427
- When reading the audit trail, use `kind` to distinguish automated pipeline progression from manual repair operations. Do not conflate the two when building reports or alerting rules.
428
-
429
- ## Verify
430
-
431
- Run the relevant checks from the project root:
432
-
433
- ```bash
434
- pnpm -C operations run check
435
- pnpm -C ui run check
436
- ```
437
-
438
- For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:
439
-
440
- ```bash
441
- pnpm -C operations exec elevasis-sdk check
442
- pnpm -C operations exec elevasis-sdk deploy
443
- ```
444
-
445
-
419
+
420
+ ## Activity Log Conventions
421
+
422
+ The `acq_deal_activity_log` table uses a `kind` field to distinguish how a state transition was initiated. Two values are relevant for CRM state changes:
423
+
424
+ - `state_change` -- written by workflow steps (e.g. `crm-send-booking-link.ts` transitioning to `discovery_link_sent`). Carries an `action_taken` payload identifying the workflow.
425
+ - `state_changed_manually` -- written by the `PATCH /api/deals/:dealId/state` route when an operator edits `state_key` directly from the UI. Carries the user id and the before/after state. This is a pure column update with no workflow side-effects.
426
+
427
+ When reading the audit trail, use `kind` to distinguish automated pipeline progression from manual repair operations. Do not conflate the two when building reports or alerting rules.
428
+
429
+ ## Verify
430
+
431
+ Run the relevant checks from the project root:
432
+
433
+ ```bash
434
+ pnpm -C operations run check
435
+ pnpm -C ui run check
436
+ ```
437
+
438
+ For a workflow-backed action, deploy or run the workflow smoke before wiring it into the UI:
439
+
440
+ ```bash
441
+ pnpm -C operations exec elevasis-sdk check
442
+ pnpm -C operations exec elevasis-sdk deploy
443
+ ```
444
+
445
+