@elevasis/core 0.13.0 → 0.14.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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +9 -2
- package/dist/organization-model/index.d.ts +1 -1
- package/dist/organization-model/index.js +9 -2
- package/dist/test-utils/index.d.ts +463 -377
- package/dist/test-utils/index.js +9 -2
- package/package.json +1 -1
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +2324 -0
- package/src/business/acquisition/activity-events.test.ts +250 -0
- package/src/business/acquisition/activity-events.ts +7 -65
- package/src/business/acquisition/api-schemas.test.ts +1180 -0
- package/src/business/acquisition/api-schemas.ts +1075 -859
- package/src/business/acquisition/crm-state-actions.test.ts +160 -0
- package/src/business/acquisition/derive-actions.test.ts +518 -0
- package/src/business/acquisition/derive-actions.ts +103 -90
- package/src/business/acquisition/index.ts +149 -111
- package/src/business/acquisition/stateful.ts +30 -0
- package/src/business/acquisition/types.ts +44 -77
- package/src/execution/engine/index.ts +437 -434
- package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +363 -360
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/get-record/index.test.ts +162 -186
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-records/index.test.ts +316 -338
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-adapter.ts +204 -210
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.test.ts +88 -0
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.ts +141 -134
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/utils/types.ts +76 -75
- package/src/execution/engine/tools/integration/service.test.ts +34 -9
- package/src/execution/engine/tools/integration/service.ts +6 -3
- package/src/execution/engine/tools/lead-service-types.ts +945 -888
- package/src/execution/engine/tools/platform/acquisition/types.ts +266 -260
- package/src/execution/engine/tools/registry.ts +701 -699
- package/src/execution/engine/tools/tool-maps.ts +816 -791
- package/src/execution/engine/workflow/types.ts +11 -0
- package/src/organization-model/contracts.ts +4 -4
- package/src/organization-model/domains/navigation.ts +62 -62
- package/src/organization-model/domains/sales.ts +272 -0
- package/src/organization-model/published.ts +21 -21
- package/src/organization-model/resolve.ts +21 -8
- package/src/platform/constants/versions.ts +1 -1
- package/src/reference/_generated/contracts.md +2324 -0
- package/src/supabase/database.types.ts +2958 -2886
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { DEFAULT_ORGANIZATION_MODEL_SALES, LEAD_GEN_PIPELINE_DEFINITIONS } from '../../organization-model/domains/sales'
|
|
3
|
+
import { ActivityEventSchema } from './activity-events'
|
|
4
|
+
import { DealStageSchema, TransitionItemRequestSchema } from './api-schemas'
|
|
5
|
+
import { deriveActions } from './derive-actions'
|
|
6
|
+
import type { DealStage } from './types'
|
|
7
|
+
|
|
8
|
+
const DEAL_STAGES = [
|
|
9
|
+
'interested',
|
|
10
|
+
'proposal',
|
|
11
|
+
'closing',
|
|
12
|
+
'closed_won',
|
|
13
|
+
'closed_lost',
|
|
14
|
+
'nurturing'
|
|
15
|
+
] as const satisfies readonly DealStage[]
|
|
16
|
+
|
|
17
|
+
function deal(stageKey: string | null, stateKey: string | null = null): Parameters<typeof deriveActions>[0] {
|
|
18
|
+
return { stage_key: stageKey, state_key: stateKey } as Parameters<typeof deriveActions>[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('CRM deal action derivation', () => {
|
|
22
|
+
it('derives the exact base actions for interested deals', () => {
|
|
23
|
+
expect(deriveActions(deal('interested'))).toEqual([
|
|
24
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
25
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
26
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
27
|
+
])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
['discovery_replied', { key: 'send_link', label: 'Send Booking Link' }],
|
|
32
|
+
['discovery_link_sent', { key: 'send_nudge', label: 'Send Nudge' }],
|
|
33
|
+
['discovery_booking_cancelled', { key: 'rebook', label: 'Rebook' }]
|
|
34
|
+
] as const)('adds the expected workflow action for interested/%s', (stateKey, expectedAction) => {
|
|
35
|
+
const expected = [
|
|
36
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
37
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
38
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
39
|
+
expectedAction
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
if (stateKey === 'discovery_replied' || stateKey === 'discovery_link_sent') {
|
|
43
|
+
expected.splice(3, 0, {
|
|
44
|
+
key: 'send_reply',
|
|
45
|
+
label: 'Send Reply',
|
|
46
|
+
payloadSchema: expect.any(Object)
|
|
47
|
+
} as never)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(deriveActions(deal('interested', stateKey))).toEqual(expected)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('adds reply, nudge, and no-show actions for interested/discovery_nudging', () => {
|
|
54
|
+
expect(deriveActions(deal('interested', 'discovery_nudging'))).toEqual([
|
|
55
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
56
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
57
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
58
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: expect.any(Object) },
|
|
59
|
+
{ key: 'send_nudge', label: 'Send Nudge' },
|
|
60
|
+
{ key: 'mark_no_show', label: 'Mark No-Show' }
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('derives exact proposal and closing transitions', () => {
|
|
65
|
+
expect(deriveActions(deal('proposal'))).toEqual([
|
|
66
|
+
{ key: 'move_to_closing', label: 'Move to Closing' },
|
|
67
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
68
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
expect(deriveActions(deal('closing'))).toEqual([
|
|
72
|
+
{ key: 'move_to_closed_won', label: 'Close Won' },
|
|
73
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
74
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', 'legacy_stage', null] as const)(
|
|
79
|
+
'derives no actions for terminal or non-canonical stage %s',
|
|
80
|
+
(stageKey) => {
|
|
81
|
+
expect(deriveActions(deal(stageKey))).toEqual([])
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('ActivityEventSchema', () => {
|
|
87
|
+
const timestamp = '2026-04-27T12:34:56.000Z'
|
|
88
|
+
|
|
89
|
+
// Platform events only (8 members). Domain events (reply_received, booking_nudge_sent, etc.)
|
|
90
|
+
// live in external/elevasis/operations as CrmDomainActivityEventSchema.
|
|
91
|
+
// See Open Decision #5 in crm-action-system.mdx for rationale.
|
|
92
|
+
it.each([
|
|
93
|
+
[{ type: 'stage_change', timestamp, stageBefore: 'interested', stageAfter: 'proposal', reason: 'qualified' }],
|
|
94
|
+
[{ type: 'state_change', timestamp, stateBefore: null, stateAfter: 'discovery_replied' }],
|
|
95
|
+
[{ type: 'action_taken', timestamp, actionKey: 'send_link', payload: { channel: 'email' } }],
|
|
96
|
+
[{ type: 'approval_created', timestamp, commandId: 'cmd_123', dealStageBefore: 'proposal' }],
|
|
97
|
+
[{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'superseded' }],
|
|
98
|
+
[{ type: 'approval_stale', timestamp, commandId: 'cmd_123', dealStageAfter: 'closing' }],
|
|
99
|
+
[{ type: 'task_created', timestamp, taskId: 'task_123' }],
|
|
100
|
+
[{ type: 'deal_created', timestamp }]
|
|
101
|
+
])('accepts expected %s events', (event) => {
|
|
102
|
+
expect(ActivityEventSchema.safeParse(event).success).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
[{ type: 'deal_created', timestamp: 'not-a-date' }],
|
|
107
|
+
[{ type: 'unknown_event', timestamp }],
|
|
108
|
+
[{ type: 'stage_change', timestamp, stageBefore: 'interested' }],
|
|
109
|
+
[{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'approved' }],
|
|
110
|
+
[{ type: 'booking_nudge_sent', timestamp, followupDay: '2' }],
|
|
111
|
+
[{ type: 'action_taken', timestamp }]
|
|
112
|
+
])('rejects malformed event payload %o', (event) => {
|
|
113
|
+
expect(ActivityEventSchema.safeParse(event).success).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('CRM stage and transition vocabulary contracts', () => {
|
|
118
|
+
it('keeps DealStage, DealStageSchema, and the default sales pipeline stages aligned', () => {
|
|
119
|
+
const defaultPipeline = DEFAULT_ORGANIZATION_MODEL_SALES.pipelines.find(
|
|
120
|
+
(pipeline) => pipeline.id === DEFAULT_ORGANIZATION_MODEL_SALES.defaultPipelineId
|
|
121
|
+
)
|
|
122
|
+
const defaultSalesStages = defaultPipeline?.stages
|
|
123
|
+
.toSorted((left, right) => left.order - right.order)
|
|
124
|
+
.map((stage) => stage.id)
|
|
125
|
+
|
|
126
|
+
expect(defaultSalesStages).toEqual([...DEAL_STAGES])
|
|
127
|
+
expect(DealStageSchema.options).toEqual([...DEAL_STAGES])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('accepts every canonical CRM deal stage in transition requests', () => {
|
|
131
|
+
for (const stageKey of DEAL_STAGES) {
|
|
132
|
+
expect(
|
|
133
|
+
TransitionItemRequestSchema.safeParse({
|
|
134
|
+
pipelineKey: DEFAULT_ORGANIZATION_MODEL_SALES.defaultPipelineId,
|
|
135
|
+
stageKey,
|
|
136
|
+
stateKey: null,
|
|
137
|
+
expectedUpdatedAt: '2026-04-27T12:34:56.000Z'
|
|
138
|
+
}).success
|
|
139
|
+
).toBe(true)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('accepts canonical lead-gen stage/state pairs in transition requests', () => {
|
|
144
|
+
for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
|
|
145
|
+
for (const pipeline of pipelineDefinitions) {
|
|
146
|
+
for (const stage of pipeline.stages) {
|
|
147
|
+
for (const state of stage.states) {
|
|
148
|
+
expect(
|
|
149
|
+
TransitionItemRequestSchema.safeParse({
|
|
150
|
+
pipelineKey: pipeline.pipelineKey,
|
|
151
|
+
stageKey: stage.stageKey,
|
|
152
|
+
stateKey: state.stateKey
|
|
153
|
+
}).success
|
|
154
|
+
).toBe(true)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AcqDealRow } from './types'
|
|
3
|
+
import type { Action, ActionDef } from './derive-actions'
|
|
4
|
+
import { DEFAULT_CRM_ACTIONS, SendReplyActionPayloadSchema, deriveActions } from './derive-actions'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixture builder
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build a minimal AcqDealRow suitable for `isAvailableFor()` predicate checks.
|
|
12
|
+
* Only stage_key and state_key affect availability logic; all other required
|
|
13
|
+
* columns are filled with safe, inert defaults.
|
|
14
|
+
*/
|
|
15
|
+
function makeDeal(overrides: Partial<Pick<AcqDealRow, 'stage_key' | 'state_key'>> = {}): AcqDealRow {
|
|
16
|
+
return {
|
|
17
|
+
id: 'deal-fixture-id',
|
|
18
|
+
organization_id: 'org-fixture-id',
|
|
19
|
+
contact_id: null,
|
|
20
|
+
contact_email: 'fixture@example.com',
|
|
21
|
+
pipeline_key: 'default',
|
|
22
|
+
stage_key: null,
|
|
23
|
+
state_key: null,
|
|
24
|
+
activity_log: [],
|
|
25
|
+
closed_lost_at: null,
|
|
26
|
+
closed_lost_reason: null,
|
|
27
|
+
discovery_data: null,
|
|
28
|
+
discovery_submitted_at: null,
|
|
29
|
+
discovery_submitted_by: null,
|
|
30
|
+
initial_fee: null,
|
|
31
|
+
monthly_fee: null,
|
|
32
|
+
payment_link_sent_at: null,
|
|
33
|
+
payment_received_at: null,
|
|
34
|
+
proposal_data: null,
|
|
35
|
+
proposal_generated_at: null,
|
|
36
|
+
proposal_pdf_url: null,
|
|
37
|
+
proposal_reviewed_at: null,
|
|
38
|
+
proposal_reviewed_by: null,
|
|
39
|
+
proposal_sent_at: null,
|
|
40
|
+
proposal_signed_at: null,
|
|
41
|
+
signature_envelope_id: null,
|
|
42
|
+
source_list_id: null,
|
|
43
|
+
source_type: null,
|
|
44
|
+
stripe_payment_id: null,
|
|
45
|
+
stripe_payment_link: null,
|
|
46
|
+
stripe_payment_link_id: null,
|
|
47
|
+
stripe_subscription_id: null,
|
|
48
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
49
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
50
|
+
...overrides
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// DEFAULT_CRM_ACTIONS static contract
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
describe('DEFAULT_CRM_ACTIONS static contract', () => {
|
|
59
|
+
const EXPECTED_KEYS = [
|
|
60
|
+
'move_to_proposal',
|
|
61
|
+
'move_to_closing',
|
|
62
|
+
'move_to_closed_won',
|
|
63
|
+
'move_to_closed_lost',
|
|
64
|
+
'move_to_nurturing',
|
|
65
|
+
'send_reply',
|
|
66
|
+
'send_link',
|
|
67
|
+
'send_nudge',
|
|
68
|
+
'mark_no_show',
|
|
69
|
+
'rebook'
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
it('contains all 10 expected action keys', () => {
|
|
73
|
+
const keys = DEFAULT_CRM_ACTIONS.map((a) => a.key)
|
|
74
|
+
expect(keys).toEqual(EXPECTED_KEYS)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('every entry has key, label, isAvailableFor, and workflowId', () => {
|
|
78
|
+
for (const def of DEFAULT_CRM_ACTIONS) {
|
|
79
|
+
expect(typeof def.key).toBe('string')
|
|
80
|
+
expect(def.key.length).toBeGreaterThan(0)
|
|
81
|
+
expect(typeof def.label).toBe('string')
|
|
82
|
+
expect(def.label.length).toBeGreaterThan(0)
|
|
83
|
+
expect(typeof def.isAvailableFor).toBe('function')
|
|
84
|
+
expect(typeof def.workflowId).toBe('string')
|
|
85
|
+
expect(def.workflowId.length).toBeGreaterThan(0)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('no two ActionDefs share the same key', () => {
|
|
90
|
+
const keys = DEFAULT_CRM_ACTIONS.map((a) => a.key)
|
|
91
|
+
const unique = new Set(keys)
|
|
92
|
+
expect(unique.size).toBe(keys.length)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// isAvailableFor() predicates — per stage / state
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('isAvailableFor() predicates', () => {
|
|
101
|
+
describe('move_to_proposal', () => {
|
|
102
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_proposal')!
|
|
103
|
+
|
|
104
|
+
it('is available for interested stage', () => {
|
|
105
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested' }))).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it.each(['proposal', 'closing', 'closed_won', 'closed_lost', 'nurturing', null])(
|
|
109
|
+
'is NOT available for stage %s',
|
|
110
|
+
(stage) => {
|
|
111
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('move_to_closing', () => {
|
|
117
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_closing')!
|
|
118
|
+
|
|
119
|
+
it('is available for proposal stage', () => {
|
|
120
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'proposal' }))).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it.each(['interested', 'closing', 'closed_won', 'closed_lost', 'nurturing', null])(
|
|
124
|
+
'is NOT available for stage %s',
|
|
125
|
+
(stage) => {
|
|
126
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('move_to_closed_won', () => {
|
|
132
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_closed_won')!
|
|
133
|
+
|
|
134
|
+
it('is available for closing stage', () => {
|
|
135
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'closing' }))).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it.each(['interested', 'proposal', 'closed_won', 'closed_lost', 'nurturing', null])(
|
|
139
|
+
'is NOT available for stage %s',
|
|
140
|
+
(stage) => {
|
|
141
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('move_to_closed_lost', () => {
|
|
147
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_closed_lost')!
|
|
148
|
+
|
|
149
|
+
it.each(['interested', 'proposal', 'closing'])('is available for stage %s', (stage) => {
|
|
150
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', null])('is NOT available for stage %s', (stage) => {
|
|
154
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('move_to_nurturing', () => {
|
|
159
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'move_to_nurturing')!
|
|
160
|
+
|
|
161
|
+
it.each(['interested', 'proposal', 'closing'])('is available for stage %s', (stage) => {
|
|
162
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', null])('is NOT available for stage %s', (stage) => {
|
|
166
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: stage }))).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('send_link', () => {
|
|
171
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'send_link')!
|
|
172
|
+
|
|
173
|
+
it('is available for interested/discovery_replied', () => {
|
|
174
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' }))).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it.each([
|
|
178
|
+
{ stage_key: 'interested', state_key: null },
|
|
179
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
180
|
+
{ stage_key: 'interested', state_key: 'discovery_nudging' },
|
|
181
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
182
|
+
{ stage_key: 'proposal', state_key: 'discovery_replied' },
|
|
183
|
+
{ stage_key: null, state_key: null }
|
|
184
|
+
])('is NOT available for %o', (overrides) => {
|
|
185
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('send_nudge', () => {
|
|
190
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'send_nudge')!
|
|
191
|
+
|
|
192
|
+
it.each(['discovery_link_sent', 'discovery_nudging'])('is available for interested/%s', (state) => {
|
|
193
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: state }))).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it.each([
|
|
197
|
+
{ stage_key: 'interested', state_key: null },
|
|
198
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
199
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
200
|
+
{ stage_key: 'proposal', state_key: 'discovery_link_sent' },
|
|
201
|
+
{ stage_key: null, state_key: null }
|
|
202
|
+
])('is NOT available for %o', (overrides) => {
|
|
203
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('mark_no_show', () => {
|
|
208
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'mark_no_show')!
|
|
209
|
+
|
|
210
|
+
it('is available for interested/discovery_nudging', () => {
|
|
211
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: 'discovery_nudging' }))).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it.each([
|
|
215
|
+
{ stage_key: 'interested', state_key: null },
|
|
216
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
217
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
218
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
219
|
+
{ stage_key: 'proposal', state_key: 'discovery_nudging' },
|
|
220
|
+
{ stage_key: null, state_key: null }
|
|
221
|
+
])('is NOT available for %o', (overrides) => {
|
|
222
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('rebook', () => {
|
|
227
|
+
const def = DEFAULT_CRM_ACTIONS.find((a) => a.key === 'rebook')!
|
|
228
|
+
|
|
229
|
+
it('is available for interested/discovery_booking_cancelled', () => {
|
|
230
|
+
expect(def.isAvailableFor(makeDeal({ stage_key: 'interested', state_key: 'discovery_booking_cancelled' }))).toBe(
|
|
231
|
+
true
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it.each([
|
|
236
|
+
{ stage_key: 'interested', state_key: null },
|
|
237
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
238
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
239
|
+
{ stage_key: 'interested', state_key: 'discovery_nudging' },
|
|
240
|
+
{ stage_key: 'proposal', state_key: 'discovery_booking_cancelled' },
|
|
241
|
+
{ stage_key: null, state_key: null }
|
|
242
|
+
])('is NOT available for %o', (overrides) => {
|
|
243
|
+
expect(def.isAvailableFor(makeDeal(overrides))).toBe(false)
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('terminal stages produce empty action lists', () => {
|
|
248
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', null])('returns [] for stage %s', (stage) => {
|
|
249
|
+
expect(deriveActions(makeDeal({ stage_key: stage }))).toEqual([])
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// deriveActions() integration
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
describe('deriveActions()', () => {
|
|
259
|
+
it('returns render-time shape { key, label } only — no isAvailableFor, workflowId, or payload fields', () => {
|
|
260
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
261
|
+
|
|
262
|
+
expect(actions.length).toBeGreaterThan(0)
|
|
263
|
+
for (const action of actions) {
|
|
264
|
+
expect(action).not.toHaveProperty('isAvailableFor')
|
|
265
|
+
expect(action).not.toHaveProperty('workflowId')
|
|
266
|
+
// Old fields from prior shape
|
|
267
|
+
expect(action).not.toHaveProperty('available')
|
|
268
|
+
expect(action).not.toHaveProperty('handler')
|
|
269
|
+
expect(action).not.toHaveProperty('kind')
|
|
270
|
+
expect(action).not.toHaveProperty('payload')
|
|
271
|
+
// Required render-time fields
|
|
272
|
+
expect(action).toHaveProperty('key')
|
|
273
|
+
expect(action).toHaveProperty('label')
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('actions without payloadSchema render with payloadSchema undefined (button branch)', () => {
|
|
278
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
279
|
+
for (const action of actions) {
|
|
280
|
+
expect(action.payloadSchema).toBeUndefined()
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('custom action set works: deriveActions(deal, [customAction]) filters by the override', () => {
|
|
285
|
+
const alwaysAvailableAction: ActionDef = {
|
|
286
|
+
key: 'custom_action',
|
|
287
|
+
label: 'Custom Action',
|
|
288
|
+
isAvailableFor: () => true,
|
|
289
|
+
workflowId: 'custom-action-workflow'
|
|
290
|
+
}
|
|
291
|
+
const neverAvailableAction: ActionDef = {
|
|
292
|
+
key: 'blocked_action',
|
|
293
|
+
label: 'Blocked Action',
|
|
294
|
+
isAvailableFor: () => false,
|
|
295
|
+
workflowId: 'blocked-action-workflow'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }), [alwaysAvailableAction, neverAvailableAction])
|
|
299
|
+
|
|
300
|
+
expect(actions).toEqual([{ key: 'custom_action', label: 'Custom Action' }])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('empty action set returns empty array', () => {
|
|
304
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested' }), [])).toEqual([])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('uses DEFAULT_CRM_ACTIONS when no second argument is provided', () => {
|
|
308
|
+
const withDefaults = deriveActions(makeDeal({ stage_key: 'proposal' }))
|
|
309
|
+
const withExplicit = deriveActions(makeDeal({ stage_key: 'proposal' }), DEFAULT_CRM_ACTIONS)
|
|
310
|
+
expect(withDefaults).toEqual(withExplicit)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// SendReplyActionPayloadSchema boundary tests
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('SendReplyActionPayloadSchema', () => {
|
|
319
|
+
it('rejects empty string', () => {
|
|
320
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: '' }).success).toBe(false)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('rejects whitespace-only string after trim', () => {
|
|
324
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: ' ' }).success).toBe(false)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('accepts single character after trim', () => {
|
|
328
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'x' }).success).toBe(true)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('accepts string at the 10000-character limit', () => {
|
|
332
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'x'.repeat(10000) }).success).toBe(true)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('rejects string exceeding 10000 characters', () => {
|
|
336
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'x'.repeat(10001) }).success).toBe(false)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('trims leading and trailing whitespace from replyBody', () => {
|
|
340
|
+
const result = SendReplyActionPayloadSchema.safeParse({ replyBody: ' hello ' })
|
|
341
|
+
expect(result.success).toBe(true)
|
|
342
|
+
if (result.success) {
|
|
343
|
+
expect(result.data.replyBody).toBe('hello')
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('rejects unknown fields (strict schema)', () => {
|
|
348
|
+
expect(SendReplyActionPayloadSchema.safeParse({ replyBody: 'hello', extra: 'field' }).success).toBe(false)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// deriveActions() — custom action set isolation
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
describe('deriveActions() — custom action set isolation', () => {
|
|
357
|
+
it('filters only against the provided set — does not merge with DEFAULT_CRM_ACTIONS', () => {
|
|
358
|
+
const customOnly: ActionDef = {
|
|
359
|
+
key: 'custom_only',
|
|
360
|
+
label: 'Custom Only',
|
|
361
|
+
isAvailableFor: () => true,
|
|
362
|
+
workflowId: 'custom-only-workflow'
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const result = deriveActions(makeDeal({ stage_key: 'interested' }), [customOnly])
|
|
366
|
+
|
|
367
|
+
const resultKeys = result.map((a) => a.key)
|
|
368
|
+
expect(resultKeys).toEqual(['custom_only'])
|
|
369
|
+
const defaultKeys = DEFAULT_CRM_ACTIONS.map((a) => a.key)
|
|
370
|
+
for (const key of defaultKeys) {
|
|
371
|
+
expect(resultKeys).not.toContain(key)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('always-available custom action appears for any deal state', () => {
|
|
376
|
+
const alwaysOn: ActionDef = {
|
|
377
|
+
key: 'always_on',
|
|
378
|
+
label: 'Always On',
|
|
379
|
+
isAvailableFor: () => true,
|
|
380
|
+
workflowId: 'always-on-workflow'
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
for (const stage of ['interested', 'proposal', 'closing', 'closed_won', 'closed_lost', 'nurturing', null]) {
|
|
384
|
+
const result = deriveActions(makeDeal({ stage_key: stage }), [alwaysOn])
|
|
385
|
+
expect(result).toEqual([{ key: 'always_on', label: 'Always On' }])
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// deriveActions() — action metadata preservation
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
describe('deriveActions() — action metadata preservation', () => {
|
|
395
|
+
it('send_reply action includes payloadSchema in output', () => {
|
|
396
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' }))
|
|
397
|
+
const sendReply = actions.find((a) => a.key === 'send_reply')
|
|
398
|
+
expect(sendReply).toBeDefined()
|
|
399
|
+
expect(sendReply?.payloadSchema).toBeDefined()
|
|
400
|
+
expect(sendReply?.payloadSchema).toBe(SendReplyActionPayloadSchema)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('move_to_proposal does NOT include payloadSchema', () => {
|
|
404
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
405
|
+
const action = actions.find((a) => a.key === 'move_to_proposal')
|
|
406
|
+
expect(action).toBeDefined()
|
|
407
|
+
expect(action?.payloadSchema).toBeUndefined()
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('returned actions preserve key and label properties', () => {
|
|
411
|
+
const actions = deriveActions(makeDeal({ stage_key: 'interested' }))
|
|
412
|
+
expect(actions.length).toBeGreaterThan(0)
|
|
413
|
+
for (const action of actions) {
|
|
414
|
+
expect(typeof action.key).toBe('string')
|
|
415
|
+
expect(action.key.length).toBeGreaterThan(0)
|
|
416
|
+
expect(typeof action.label).toBe('string')
|
|
417
|
+
expect(action.label.length).toBeGreaterThan(0)
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// deriveActions() — deduplication and ordering
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
describe('deriveActions() — deduplication and ordering', () => {
|
|
427
|
+
it.each([
|
|
428
|
+
{ stage_key: 'interested', state_key: null },
|
|
429
|
+
{ stage_key: 'interested', state_key: 'discovery_replied' },
|
|
430
|
+
{ stage_key: 'interested', state_key: 'discovery_link_sent' },
|
|
431
|
+
{ stage_key: 'interested', state_key: 'discovery_nudging' },
|
|
432
|
+
{ stage_key: 'interested', state_key: 'discovery_booking_cancelled' },
|
|
433
|
+
{ stage_key: 'proposal', state_key: null },
|
|
434
|
+
{ stage_key: 'closing', state_key: null }
|
|
435
|
+
] as const)('no duplicate action keys for %o', (overrides) => {
|
|
436
|
+
const actions = deriveActions(makeDeal(overrides))
|
|
437
|
+
const keys = actions.map((a) => a.key)
|
|
438
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('returns the same order for repeated calls with identical input', () => {
|
|
442
|
+
const deal = makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' })
|
|
443
|
+
const first = deriveActions(deal).map((a) => a.key)
|
|
444
|
+
const second = deriveActions(deal).map((a) => a.key)
|
|
445
|
+
expect(first).toEqual(second)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// deriveActions() — multi-state discovery path coverage
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
describe('deriveActions() — multi-state discovery path coverage', () => {
|
|
454
|
+
it('interested + null state: base actions only, no state-specific actions', () => {
|
|
455
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: null }))).toEqual([
|
|
456
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
457
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
458
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
459
|
+
])
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('interested + discovery_replied: base actions plus send_reply and send_link', () => {
|
|
463
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_replied' }))).toEqual([
|
|
464
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
465
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
466
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
467
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema },
|
|
468
|
+
{ key: 'send_link', label: 'Send Booking Link' }
|
|
469
|
+
])
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('interested + discovery_link_sent: base actions plus send_reply and send_nudge', () => {
|
|
473
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_link_sent' }))).toEqual([
|
|
474
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
475
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
476
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
477
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema },
|
|
478
|
+
{ key: 'send_nudge', label: 'Send Nudge' }
|
|
479
|
+
])
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('interested + discovery_nudging: base actions plus send_reply, send_nudge, and mark_no_show', () => {
|
|
483
|
+
expect(deriveActions(makeDeal({ stage_key: 'interested', state_key: 'discovery_nudging' }))).toEqual([
|
|
484
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
485
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
486
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
487
|
+
{ key: 'send_reply', label: 'Send Reply', payloadSchema: SendReplyActionPayloadSchema },
|
|
488
|
+
{ key: 'send_nudge', label: 'Send Nudge' },
|
|
489
|
+
{ key: 'mark_no_show', label: 'Mark No-Show' }
|
|
490
|
+
])
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Type-level smoke tests (compile-time only, no runtime assertions)
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
describe('type-level smoke', () => {
|
|
499
|
+
it('Action and ActionDef are usable as TypeScript types', () => {
|
|
500
|
+
// If this file compiles without error, these types are valid at the use-site.
|
|
501
|
+
function acceptsAction(_action: Action): void {}
|
|
502
|
+
function acceptsActionDef(_def: ActionDef): void {}
|
|
503
|
+
|
|
504
|
+
const sampleAction: Action = { key: 'test', label: 'Test' }
|
|
505
|
+
const sampleDef: ActionDef = {
|
|
506
|
+
key: 'test',
|
|
507
|
+
label: 'Test',
|
|
508
|
+
isAvailableFor: () => true,
|
|
509
|
+
workflowId: 'test-workflow'
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
acceptsAction(sampleAction)
|
|
513
|
+
acceptsActionDef(sampleDef)
|
|
514
|
+
|
|
515
|
+
// No runtime assertion needed — compilation success is the contract
|
|
516
|
+
expect(true).toBe(true)
|
|
517
|
+
})
|
|
518
|
+
})
|