@elevasis/core 0.14.0 → 0.15.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/dist/index.d.ts +60 -0
- package/dist/index.js +198 -1
- package/dist/organization-model/index.d.ts +60 -0
- package/dist/organization-model/index.js +198 -1
- package/dist/test-utils/index.d.ts +399 -363
- package/dist/test-utils/index.js +198 -1
- package/package.json +3 -3
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +444 -309
- package/src/business/acquisition/activity-events.ts +12 -3
- package/src/business/acquisition/api-schemas.test.ts +315 -4
- package/src/business/acquisition/api-schemas.ts +140 -17
- package/src/business/acquisition/build-templates.ts +44 -0
- package/src/business/acquisition/crm-next-action.test.ts +262 -0
- package/src/business/acquisition/crm-next-action.ts +220 -0
- package/src/business/acquisition/crm-priority.test.ts +216 -0
- package/src/business/acquisition/crm-priority.ts +349 -0
- package/src/business/acquisition/crm-state-actions.test.ts +12 -21
- package/src/business/acquisition/deal-ownership.test.ts +351 -0
- package/src/business/acquisition/deal-ownership.ts +120 -0
- package/src/business/acquisition/derive-actions.test.ts +101 -37
- package/src/business/acquisition/derive-actions.ts +49 -24
- package/src/business/acquisition/index.ts +163 -149
- package/src/business/acquisition/types.ts +48 -4
- package/src/execution/engine/index.ts +4 -3
- package/src/execution/engine/tools/lead-service-types.ts +68 -51
- package/src/execution/engine/tools/platform/acquisition/list-tools.ts +6 -5
- package/src/execution/engine/tools/platform/acquisition/types.ts +3 -1
- package/src/execution/engine/tools/registry.ts +4 -3
- package/src/execution/engine/tools/tool-maps.ts +821 -816
- package/src/organization-model/domains/prospecting.ts +204 -1
- package/src/organization-model/domains/sales.test.ts +218 -0
- package/src/organization-model/domains/sales.ts +558 -366
- package/src/organization-model/types.ts +2 -2
- package/src/platform/constants/versions.ts +1 -1
- package/src/reference/_generated/contracts.md +444 -309
- package/src/supabase/database.types.ts +2978 -2958
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { UuidSchema, NonEmptyStringSchema } from '../../platform/utils/validation'
|
|
3
3
|
import { LEAD_GEN_STAGE_CATALOG } from '../../organization-model/domains/sales'
|
|
4
|
+
import { isProspectingBuildTemplateId } from './build-templates'
|
|
5
|
+
export { CrmPriorityBucketKeySchema, CrmPriorityBucketOverrideSchema, CrmPriorityOverrideSchema } from './crm-priority'
|
|
6
|
+
export type { CrmPriorityBucketOverride, CrmPriorityOverride, ResolvedCrmPriorityRuleConfig } from './crm-priority'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Deal Management API Schemas
|
|
@@ -92,6 +95,14 @@ export const TransitionItemRequestSchema = z
|
|
|
92
95
|
})
|
|
93
96
|
.strict()
|
|
94
97
|
|
|
98
|
+
export const TransitionDealStateRequestSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
stateKey: z.string().min(1),
|
|
101
|
+
reason: z.string().optional(),
|
|
102
|
+
expectedUpdatedAt: z.string().datetime().optional()
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
|
|
95
106
|
export const ExecuteActionParamsSchema = z
|
|
96
107
|
.object({
|
|
97
108
|
dealId: UuidSchema,
|
|
@@ -137,6 +148,16 @@ export const DealContactSummarySchema = z.object({
|
|
|
137
148
|
.nullable()
|
|
138
149
|
})
|
|
139
150
|
|
|
151
|
+
export const DealPrioritySchema = z.object({
|
|
152
|
+
bucketKey: z.enum(['needs_response', 'follow_up_due', 'waiting', 'stale', 'closed_low']),
|
|
153
|
+
rank: z.number().int(),
|
|
154
|
+
label: z.string(),
|
|
155
|
+
color: z.string(),
|
|
156
|
+
reason: z.string(),
|
|
157
|
+
latestActivityAt: z.string().nullable(),
|
|
158
|
+
nextActionAt: z.string().nullable()
|
|
159
|
+
})
|
|
160
|
+
|
|
140
161
|
/**
|
|
141
162
|
* Deal list item with joined contact (and company via contact).
|
|
142
163
|
* Matches DealListItem from @repo/core types.
|
|
@@ -164,11 +185,14 @@ export const DealListItemSchema = z.object({
|
|
|
164
185
|
monthly_fee: z.number().nullable(),
|
|
165
186
|
closed_lost_at: z.string().nullable(),
|
|
166
187
|
closed_lost_reason: z.string().nullable(),
|
|
167
|
-
created_at: z.string(),
|
|
168
|
-
updated_at: z.string(),
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
created_at: z.string(),
|
|
189
|
+
updated_at: z.string(),
|
|
190
|
+
priority: DealPrioritySchema,
|
|
191
|
+
ownership: z.enum(['us', 'them']).nullable(),
|
|
192
|
+
nextAction: z.string().nullable(),
|
|
193
|
+
// joined relation
|
|
194
|
+
contact: DealContactSummarySchema.nullable()
|
|
195
|
+
})
|
|
172
196
|
|
|
173
197
|
export const DealListResponseSchema = z.object({
|
|
174
198
|
data: z.array(DealListItemSchema),
|
|
@@ -217,11 +241,27 @@ export const DealLookupItemSchema = z.object({
|
|
|
217
241
|
|
|
218
242
|
export const DealLookupResponseSchema = z.array(DealLookupItemSchema)
|
|
219
243
|
|
|
244
|
+
export const ConversationMessageSchema = z.object({
|
|
245
|
+
id: z.string(),
|
|
246
|
+
direction: z.enum(['inbound', 'outbound']),
|
|
247
|
+
fromEmail: z.string(),
|
|
248
|
+
toEmail: z.string(),
|
|
249
|
+
subject: z.string().nullable(),
|
|
250
|
+
body: z.string(),
|
|
251
|
+
sentAt: z.string().nullable()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
export const DealConversationSchema = z.object({
|
|
255
|
+
messages: z.array(ConversationMessageSchema)
|
|
256
|
+
})
|
|
257
|
+
|
|
220
258
|
/**
|
|
221
259
|
* Deal detail shape — currently the same as a list item (full joined record).
|
|
222
|
-
*
|
|
260
|
+
* Additive fields keep existing DealListItem callers compatible.
|
|
223
261
|
*/
|
|
224
|
-
export const DealDetailResponseSchema = DealListItemSchema
|
|
262
|
+
export const DealDetailResponseSchema = DealListItemSchema.extend({
|
|
263
|
+
conversation: DealConversationSchema
|
|
264
|
+
})
|
|
225
265
|
|
|
226
266
|
/**
|
|
227
267
|
* Single acq_deal_notes row (camelCase API representation).
|
|
@@ -278,13 +318,16 @@ export const DealSchemas = {
|
|
|
278
318
|
CreateDealNoteRequest: CreateDealNoteRequestSchema,
|
|
279
319
|
CreateDealTaskRequest: CreateDealTaskRequestSchema,
|
|
280
320
|
TransitionItemRequest: TransitionItemRequestSchema,
|
|
321
|
+
TransitionDealStateRequest: TransitionDealStateRequestSchema,
|
|
281
322
|
ExecuteActionParams: ExecuteActionParamsSchema,
|
|
282
323
|
ExecuteActionRequest: ExecuteActionRequestSchema,
|
|
283
324
|
|
|
284
325
|
// Responses
|
|
326
|
+
DealPriority: DealPrioritySchema,
|
|
285
327
|
DealListResponse: DealListResponseSchema,
|
|
286
328
|
DealSummaryResponse: DealSummaryResponseSchema,
|
|
287
329
|
DealLookupResponse: DealLookupResponseSchema,
|
|
330
|
+
ConversationMessage: ConversationMessageSchema,
|
|
288
331
|
DealDetailResponse: DealDetailResponseSchema,
|
|
289
332
|
DealNoteResponse: DealNoteResponseSchema,
|
|
290
333
|
DealNoteListResponse: DealNoteListResponseSchema,
|
|
@@ -306,12 +349,15 @@ export type ListDealTasksDueQuery = z.infer<typeof ListDealTasksDueQuerySchema>
|
|
|
306
349
|
export type CreateDealNoteRequest = z.infer<typeof CreateDealNoteRequestSchema>
|
|
307
350
|
export type CreateDealTaskRequest = z.infer<typeof CreateDealTaskRequestSchema>
|
|
308
351
|
export type TransitionItemRequest = z.infer<typeof TransitionItemRequestSchema>
|
|
352
|
+
export type TransitionDealStateRequest = z.infer<typeof TransitionDealStateRequestSchema>
|
|
309
353
|
export type ExecuteActionParams = z.infer<typeof ExecuteActionParamsSchema>
|
|
310
354
|
export type ExecuteActionRequest = z.infer<typeof ExecuteActionRequestSchema>
|
|
355
|
+
export type DealPriorityResponse = z.infer<typeof DealPrioritySchema>
|
|
311
356
|
export type DealListResponse = z.infer<typeof DealListResponseSchema>
|
|
312
357
|
export type DealSummaryResponse = z.infer<typeof DealSummaryResponseSchema>
|
|
313
358
|
export type DealLookupItem = z.infer<typeof DealLookupItemSchema>
|
|
314
359
|
export type DealLookupResponse = z.infer<typeof DealLookupResponseSchema>
|
|
360
|
+
export type ConversationMessage = z.infer<typeof ConversationMessageSchema>
|
|
315
361
|
export type DealDetailResponse = z.infer<typeof DealDetailResponseSchema>
|
|
316
362
|
export type DealNoteResponse = z.infer<typeof DealNoteResponseSchema>
|
|
317
363
|
export type DealNoteListResponse = z.infer<typeof DealNoteListResponseSchema>
|
|
@@ -389,11 +435,53 @@ export const PipelineConfigSchema = z.object({
|
|
|
389
435
|
stages: z.array(PipelineStageSchema).optional()
|
|
390
436
|
})
|
|
391
437
|
|
|
438
|
+
export const BuildPlanSnapshotStepSchema = z
|
|
439
|
+
.object({
|
|
440
|
+
id: z.string().trim().min(1).max(100),
|
|
441
|
+
label: z.string().trim().min(1).max(120),
|
|
442
|
+
description: z.string().trim().min(1).max(2000).optional(),
|
|
443
|
+
primaryEntity: z.enum(['company', 'contact']),
|
|
444
|
+
outputs: z.array(z.enum(['company', 'contact', 'export'])).min(1),
|
|
445
|
+
stageKey: z.string().trim().min(1).max(100),
|
|
446
|
+
dependsOn: z.array(z.string().trim().min(1).max(100)).optional(),
|
|
447
|
+
dependencyMode: z.literal('per-record-eligibility'),
|
|
448
|
+
capabilityKey: z.string().trim().min(1).max(100),
|
|
449
|
+
defaultBatchSize: z.number().int().positive(),
|
|
450
|
+
maxBatchSize: z.number().int().positive()
|
|
451
|
+
})
|
|
452
|
+
.refine((step) => step.defaultBatchSize <= step.maxBatchSize, {
|
|
453
|
+
message: 'defaultBatchSize must be less than or equal to maxBatchSize',
|
|
454
|
+
path: ['defaultBatchSize']
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
export const BuildPlanSnapshotSchema = z.object({
|
|
458
|
+
templateId: z.string().trim().min(1).max(100),
|
|
459
|
+
templateLabel: z.string().trim().min(1).max(120),
|
|
460
|
+
steps: z.array(BuildPlanSnapshotStepSchema).min(1)
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
export const AcqListMetadataSchema = z
|
|
464
|
+
.object({
|
|
465
|
+
buildPlanSnapshot: BuildPlanSnapshotSchema.optional()
|
|
466
|
+
})
|
|
467
|
+
.catchall(z.unknown())
|
|
468
|
+
|
|
469
|
+
export const ProspectingBuildTemplateIdSchema = z
|
|
470
|
+
.string()
|
|
471
|
+
.trim()
|
|
472
|
+
.min(1)
|
|
473
|
+
.max(100)
|
|
474
|
+
.refine(isProspectingBuildTemplateId, {
|
|
475
|
+
message: 'buildTemplateId must match a known prospecting build template'
|
|
476
|
+
})
|
|
477
|
+
|
|
392
478
|
// ---------------------------------------------------------------------------
|
|
393
479
|
// List telemetry / progress schemas
|
|
394
480
|
// ---------------------------------------------------------------------------
|
|
395
481
|
|
|
396
482
|
export const ListStageCountsSchema = z.object({
|
|
483
|
+
// Attempted counts by canonical lead-gen stage. The detailed status
|
|
484
|
+
// distribution lives on ListProgress; telemetry keeps the overview payload small.
|
|
397
485
|
stageCounts: z.object({
|
|
398
486
|
populated: z.number().int(),
|
|
399
487
|
extracted: z.number().int(),
|
|
@@ -438,6 +526,7 @@ export const CreateListRequestSchema = z
|
|
|
438
526
|
name: z.string().trim().min(1).max(255),
|
|
439
527
|
description: z.string().trim().nullable().optional(),
|
|
440
528
|
status: ListStatusSchema.optional(),
|
|
529
|
+
buildTemplateId: ProspectingBuildTemplateIdSchema.optional(),
|
|
441
530
|
scrapingConfig: ScrapingConfigSchema.optional(),
|
|
442
531
|
icp: IcpRubricSchema.optional(),
|
|
443
532
|
pipelineConfig: PipelineConfigSchema.optional()
|
|
@@ -448,11 +537,24 @@ export const UpdateListRequestSchema = z
|
|
|
448
537
|
.object({
|
|
449
538
|
name: z.string().trim().min(1).max(255).optional(),
|
|
450
539
|
description: z.string().trim().nullable().optional(),
|
|
451
|
-
batchIds: z.array(z.string()).optional()
|
|
540
|
+
batchIds: z.array(z.string()).optional(),
|
|
541
|
+
buildTemplateId: ProspectingBuildTemplateIdSchema.optional(),
|
|
542
|
+
confirmBuildTemplateChange: z.literal(true).optional()
|
|
452
543
|
})
|
|
453
544
|
.strict()
|
|
454
|
-
.refine(
|
|
455
|
-
|
|
545
|
+
.refine(
|
|
546
|
+
(data) =>
|
|
547
|
+
data.name !== undefined ||
|
|
548
|
+
data.description !== undefined ||
|
|
549
|
+
data.batchIds !== undefined ||
|
|
550
|
+
data.buildTemplateId !== undefined,
|
|
551
|
+
{
|
|
552
|
+
message: 'At least one field (name, description, batchIds, or buildTemplateId) must be provided'
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
.refine((data) => data.buildTemplateId === undefined || data.confirmBuildTemplateChange === true, {
|
|
556
|
+
message: 'confirmBuildTemplateChange must be true when changing buildTemplateId',
|
|
557
|
+
path: ['confirmBuildTemplateChange']
|
|
456
558
|
})
|
|
457
559
|
|
|
458
560
|
/**
|
|
@@ -519,12 +621,11 @@ export const AcqListResponseSchema = z.object({
|
|
|
519
621
|
organizationId: z.string(),
|
|
520
622
|
name: z.string(),
|
|
521
623
|
description: z.string().nullable(),
|
|
522
|
-
type: z.string(),
|
|
523
624
|
batchIds: z.array(z.string()),
|
|
524
625
|
instantlyCampaignId: z.string().nullable(),
|
|
525
626
|
/** Lifecycle status (draft | enriching | launched | closing | archived). */
|
|
526
627
|
status: ListStatusSchema,
|
|
527
|
-
metadata:
|
|
628
|
+
metadata: AcqListMetadataSchema,
|
|
528
629
|
launchedAt: z.string().nullable(),
|
|
529
630
|
completedAt: z.string().nullable(),
|
|
530
631
|
createdAt: z.string(),
|
|
@@ -542,20 +643,34 @@ export const ListTelemetryResponseSchema = ListTelemetrySchema
|
|
|
542
643
|
|
|
543
644
|
export const ListTelemetryListResponseSchema = z.array(ListTelemetrySchema)
|
|
544
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Terminal row-level status for one lead-gen processing stage.
|
|
648
|
+
* Missing key still means not attempted; legacy boolean `true` is normalized
|
|
649
|
+
* to `success` by the API reader during rollout.
|
|
650
|
+
*/
|
|
651
|
+
export const ProcessingStageStatusSchema = z.enum(['success', 'no_result', 'skipped', 'error'])
|
|
652
|
+
|
|
545
653
|
/**
|
|
546
654
|
* Per-stage progress aggregate for a single pipeline stage.
|
|
547
|
-
* `
|
|
655
|
+
* `attempted` counts terminal statuses, including success, no-result, skipped,
|
|
656
|
+
* error, and tolerant-reader `other` values.
|
|
548
657
|
* `total` = total member/company count for the list.
|
|
549
658
|
*/
|
|
550
659
|
export const ListStageProgressSchema = z.object({
|
|
551
|
-
|
|
552
|
-
|
|
660
|
+
total: z.number().int().min(0),
|
|
661
|
+
attempted: z.number().int().min(0),
|
|
662
|
+
success: z.number().int().min(0),
|
|
663
|
+
noResult: z.number().int().min(0),
|
|
664
|
+
skipped: z.number().int().min(0),
|
|
665
|
+
error: z.number().int().min(0),
|
|
666
|
+
other: z.number().int().min(0),
|
|
667
|
+
notAttempted: z.number().int().min(0)
|
|
553
668
|
})
|
|
554
669
|
|
|
555
670
|
/**
|
|
556
671
|
* Progress response for GET /acquisition/lists/:listId/progress.
|
|
557
|
-
* Aggregated on-demand
|
|
558
|
-
*
|
|
672
|
+
* Aggregated on-demand from processing_state status values.
|
|
673
|
+
* Stage keys are discovered from observed processing_state keys.
|
|
559
674
|
*/
|
|
560
675
|
export const ListProgressResponseSchema = z.object({
|
|
561
676
|
totalMembers: z.number().int().min(0),
|
|
@@ -979,6 +1094,10 @@ export const AcqListSchemas = {
|
|
|
979
1094
|
IcpRubric: IcpRubricSchema,
|
|
980
1095
|
PipelineConfig: PipelineConfigSchema,
|
|
981
1096
|
PipelineStage: PipelineStageSchema,
|
|
1097
|
+
BuildPlanSnapshot: BuildPlanSnapshotSchema,
|
|
1098
|
+
BuildPlanSnapshotStep: BuildPlanSnapshotStepSchema,
|
|
1099
|
+
AcqListMetadata: AcqListMetadataSchema,
|
|
1100
|
+
ProcessingStageStatus: ProcessingStageStatusSchema,
|
|
982
1101
|
ListStageCounts: ListStageCountsSchema,
|
|
983
1102
|
ListTelemetry: ListTelemetrySchema,
|
|
984
1103
|
|
|
@@ -1054,6 +1173,10 @@ export type ScrapingConfig = z.infer<typeof ScrapingConfigSchema>
|
|
|
1054
1173
|
export type IcpRubric = z.infer<typeof IcpRubricSchema>
|
|
1055
1174
|
export type PipelineStage = z.infer<typeof PipelineStageSchema>
|
|
1056
1175
|
export type PipelineConfig = z.infer<typeof PipelineConfigSchema>
|
|
1176
|
+
export type BuildPlanSnapshotStep = z.infer<typeof BuildPlanSnapshotStepSchema>
|
|
1177
|
+
export type BuildPlanSnapshot = z.infer<typeof BuildPlanSnapshotSchema>
|
|
1178
|
+
export type AcqListMetadata = z.infer<typeof AcqListMetadataSchema>
|
|
1179
|
+
export type ProcessingStageStatus = z.infer<typeof ProcessingStageStatusSchema>
|
|
1057
1180
|
export type ListStageCountsInput = z.infer<typeof ListStageCountsSchema>['stageCounts']
|
|
1058
1181
|
export type ListTelemetryInput = z.infer<typeof ListTelemetrySchema>
|
|
1059
1182
|
export type ListIdParams = z.infer<typeof ListIdParamsSchema>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DEFAULT_ORGANIZATION_MODEL_PROSPECTING } from '../../organization-model/domains/prospecting'
|
|
2
|
+
import type { BuildPlanSnapshot, BuildPlanSnapshotStep } from './types'
|
|
3
|
+
|
|
4
|
+
export const PROSPECTING_BUILD_TEMPLATE_OPTIONS = DEFAULT_ORGANIZATION_MODEL_PROSPECTING.buildTemplates.map(
|
|
5
|
+
({ id, label, description }) => ({
|
|
6
|
+
id,
|
|
7
|
+
label,
|
|
8
|
+
description
|
|
9
|
+
})
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_PROSPECTING_BUILD_TEMPLATE_ID = DEFAULT_ORGANIZATION_MODEL_PROSPECTING.defaultBuildTemplateId
|
|
13
|
+
|
|
14
|
+
export function isProspectingBuildTemplateId(value: string): boolean {
|
|
15
|
+
return PROSPECTING_BUILD_TEMPLATE_OPTIONS.some((template) => template.id === value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createBuildPlanSnapshotFromTemplateId(templateId: string): BuildPlanSnapshot | null {
|
|
19
|
+
const template = DEFAULT_ORGANIZATION_MODEL_PROSPECTING.buildTemplates.find((item) => item.id === templateId)
|
|
20
|
+
if (!template) return null
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
templateId: template.id,
|
|
24
|
+
templateLabel: template.label,
|
|
25
|
+
steps: template.steps.map((step): BuildPlanSnapshotStep => {
|
|
26
|
+
const snapshotStep: BuildPlanSnapshotStep = {
|
|
27
|
+
id: step.id,
|
|
28
|
+
label: step.label,
|
|
29
|
+
primaryEntity: step.primaryEntity,
|
|
30
|
+
outputs: [...step.outputs],
|
|
31
|
+
stageKey: step.stageKey,
|
|
32
|
+
dependencyMode: step.dependencyMode,
|
|
33
|
+
capabilityKey: step.capabilityKey,
|
|
34
|
+
defaultBatchSize: step.defaultBatchSize,
|
|
35
|
+
maxBatchSize: step.maxBatchSize
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (step.description) snapshotStep.description = step.description
|
|
39
|
+
if (step.dependsOn?.length) snapshotStep.dependsOn = [...step.dependsOn]
|
|
40
|
+
|
|
41
|
+
return snapshotStep
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { evaluateCrmDealNextAction, resolveCrmNextActionRuleConfig } from './crm-next-action'
|
|
3
|
+
import { DEFAULT_CRM_NEXT_ACTION_RULE_CONFIG } from '../../organization-model/domains/sales'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeEvent(type: string, timestamp: string): Record<string, unknown> {
|
|
10
|
+
return { type, timestamp }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const INBOUND = 'reply_received'
|
|
14
|
+
const OUTBOUND = 'reply_sent_to_lead'
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Closed deals → null
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
describe('evaluateCrmDealNextAction — closed deals', () => {
|
|
21
|
+
it('returns null for closed_won regardless of activity', () => {
|
|
22
|
+
expect(
|
|
23
|
+
evaluateCrmDealNextAction({
|
|
24
|
+
stage_key: 'interested',
|
|
25
|
+
state_key: 'closed_won',
|
|
26
|
+
activity_log: [makeEvent(INBOUND, '2026-01-10T10:00:00Z')]
|
|
27
|
+
})
|
|
28
|
+
).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns null for closed_lost regardless of activity', () => {
|
|
32
|
+
expect(
|
|
33
|
+
evaluateCrmDealNextAction({
|
|
34
|
+
stage_key: 'interested',
|
|
35
|
+
state_key: 'closed_lost',
|
|
36
|
+
activity_log: [makeEvent(INBOUND, '2026-01-10T10:00:00Z')]
|
|
37
|
+
})
|
|
38
|
+
).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns null for closed stages even when state_key is still actionable', () => {
|
|
42
|
+
expect(
|
|
43
|
+
evaluateCrmDealNextAction({
|
|
44
|
+
stage_key: 'closed_won',
|
|
45
|
+
state_key: 'discovery_replied',
|
|
46
|
+
activity_log: [makeEvent(INBOUND, '2026-01-10T10:00:00Z')]
|
|
47
|
+
})
|
|
48
|
+
).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns null when activity_log is empty (ownership null)', () => {
|
|
52
|
+
expect(
|
|
53
|
+
evaluateCrmDealNextAction({
|
|
54
|
+
stage_key: 'interested',
|
|
55
|
+
state_key: 'discovery_replied',
|
|
56
|
+
activity_log: []
|
|
57
|
+
})
|
|
58
|
+
).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// (state_key, ownership) mapping table
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('evaluateCrmDealNextAction — mapping table', () => {
|
|
67
|
+
it('(discovery_replied, us) → send_reply', () => {
|
|
68
|
+
const result = evaluateCrmDealNextAction({
|
|
69
|
+
stage_key: 'interested',
|
|
70
|
+
state_key: 'discovery_replied',
|
|
71
|
+
activity_log: [makeEvent(INBOUND, '2026-04-20T10:00:00Z')]
|
|
72
|
+
})
|
|
73
|
+
expect(result).toBe('send_reply')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('(discovery_nudging, them) → send_nudge', () => {
|
|
77
|
+
const result = evaluateCrmDealNextAction({
|
|
78
|
+
stage_key: 'interested',
|
|
79
|
+
state_key: 'discovery_nudging',
|
|
80
|
+
activity_log: [makeEvent(OUTBOUND, '2026-04-20T10:00:00Z')]
|
|
81
|
+
})
|
|
82
|
+
expect(result).toBe('send_nudge')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('(discovery_link_sent, them) → send_nudge only after staleAfterDays', () => {
|
|
86
|
+
const recentActivity = '2026-04-29T10:00:00Z'
|
|
87
|
+
const now = new Date('2026-04-30T10:00:00Z')
|
|
88
|
+
|
|
89
|
+
// Age = 1 day — below staleAfterDays (14), no mapping fires → null fallback
|
|
90
|
+
// ownership === 'them' and no stale match → falls through to null
|
|
91
|
+
const notStale = evaluateCrmDealNextAction(
|
|
92
|
+
{
|
|
93
|
+
stage_key: 'interested',
|
|
94
|
+
state_key: 'discovery_link_sent',
|
|
95
|
+
activity_log: [makeEvent(OUTBOUND, recentActivity)]
|
|
96
|
+
},
|
|
97
|
+
{ now }
|
|
98
|
+
)
|
|
99
|
+
expect(notStale).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('(discovery_link_sent, them) → send_nudge when age >= staleAfterDays', () => {
|
|
103
|
+
const oldActivity = '2026-04-01T10:00:00Z'
|
|
104
|
+
const now = new Date('2026-04-30T10:00:00Z') // 29 days later
|
|
105
|
+
|
|
106
|
+
const stale = evaluateCrmDealNextAction(
|
|
107
|
+
{
|
|
108
|
+
stage_key: 'interested',
|
|
109
|
+
state_key: 'discovery_link_sent',
|
|
110
|
+
activity_log: [makeEvent(OUTBOUND, oldActivity)]
|
|
111
|
+
},
|
|
112
|
+
{ now }
|
|
113
|
+
)
|
|
114
|
+
expect(stale).toBe('send_nudge')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('(*, us) → ownershipUsFallback send_reply when no explicit mapping matches', () => {
|
|
118
|
+
// reply_sent + inbound → ownership=us, state_key not in explicit mappings
|
|
119
|
+
const result = evaluateCrmDealNextAction({
|
|
120
|
+
stage_key: 'interested',
|
|
121
|
+
state_key: 'reply_sent',
|
|
122
|
+
activity_log: [makeEvent(INBOUND, '2026-04-20T10:00:00Z')]
|
|
123
|
+
})
|
|
124
|
+
expect(result).toBe('send_reply')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('(followup_1_sent, us) → send_reply via fallback', () => {
|
|
128
|
+
const result = evaluateCrmDealNextAction({
|
|
129
|
+
stage_key: 'interested',
|
|
130
|
+
state_key: 'followup_1_sent',
|
|
131
|
+
activity_log: [makeEvent(INBOUND, '2026-04-20T10:00:00Z')]
|
|
132
|
+
})
|
|
133
|
+
expect(result).toBe('send_reply')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('returns null when ownership is them and no mapping matches and not stale', () => {
|
|
137
|
+
const result = evaluateCrmDealNextAction({
|
|
138
|
+
stage_key: 'interested',
|
|
139
|
+
state_key: 'followup_2_sent',
|
|
140
|
+
activity_log: [makeEvent(OUTBOUND, '2026-04-29T10:00:00Z')]
|
|
141
|
+
})
|
|
142
|
+
expect(result).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// ageDays option
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
describe('evaluateCrmDealNextAction — ageDays option', () => {
|
|
151
|
+
it('uses supplied ageDays instead of computing from activity_log', () => {
|
|
152
|
+
// discovery_link_sent + them + ageDays=20 (>=14) → send_nudge
|
|
153
|
+
const result = evaluateCrmDealNextAction(
|
|
154
|
+
{
|
|
155
|
+
stage_key: 'interested',
|
|
156
|
+
state_key: 'discovery_link_sent',
|
|
157
|
+
activity_log: [makeEvent(OUTBOUND, '2026-04-29T10:00:00Z')]
|
|
158
|
+
},
|
|
159
|
+
{ ageDays: 20 }
|
|
160
|
+
)
|
|
161
|
+
expect(result).toBe('send_nudge')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('requiresStale blocks when ageDays=0', () => {
|
|
165
|
+
const result = evaluateCrmDealNextAction(
|
|
166
|
+
{
|
|
167
|
+
stage_key: 'interested',
|
|
168
|
+
state_key: 'discovery_link_sent',
|
|
169
|
+
activity_log: [makeEvent(OUTBOUND, '2026-04-29T10:00:00Z')]
|
|
170
|
+
},
|
|
171
|
+
{ ageDays: 0 }
|
|
172
|
+
)
|
|
173
|
+
expect(result).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// resolveCrmNextActionRuleConfig — defaults
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe('resolveCrmNextActionRuleConfig — defaults', () => {
|
|
182
|
+
it('returns default config when called with no input', () => {
|
|
183
|
+
const config = resolveCrmNextActionRuleConfig()
|
|
184
|
+
expect(config.ownershipUsFallback).toBe(DEFAULT_CRM_NEXT_ACTION_RULE_CONFIG.ownershipUsFallback)
|
|
185
|
+
expect(config.mappings).toHaveLength(DEFAULT_CRM_NEXT_ACTION_RULE_CONFIG.mappings.length)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('returns default config for invalid input', () => {
|
|
189
|
+
const config = resolveCrmNextActionRuleConfig('not-an-object')
|
|
190
|
+
expect(config.ownershipUsFallback).toBe(DEFAULT_CRM_NEXT_ACTION_RULE_CONFIG.ownershipUsFallback)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('accepts a full CrmNextActionRuleConfig directly', () => {
|
|
194
|
+
const full = {
|
|
195
|
+
mappings: [{ stateKey: 'foo', ownership: 'us' as const, actionKey: 'bar' }],
|
|
196
|
+
ownershipUsFallback: 'bar'
|
|
197
|
+
}
|
|
198
|
+
const config = resolveCrmNextActionRuleConfig(full)
|
|
199
|
+
expect(config.ownershipUsFallback).toBe('bar')
|
|
200
|
+
expect(config.mappings).toHaveLength(1)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Per-org override merging
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe('resolveCrmNextActionRuleConfig — per-org override', () => {
|
|
209
|
+
it('prepends org mappings before defaults (org rules win on first-match)', () => {
|
|
210
|
+
const orgConfig = {
|
|
211
|
+
crm: {
|
|
212
|
+
next_actions: {
|
|
213
|
+
mappings: [{ stateKey: 'discovery_replied', ownership: 'us' as const, actionKey: 'custom_action' }]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const config = resolveCrmNextActionRuleConfig(orgConfig)
|
|
218
|
+
// Org mapping should appear first
|
|
219
|
+
expect(config.mappings[0].actionKey).toBe('custom_action')
|
|
220
|
+
// Default mappings still present after org ones
|
|
221
|
+
expect(config.mappings.length).toBeGreaterThan(1)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('overrides ownershipUsFallback when supplied', () => {
|
|
225
|
+
const orgConfig = {
|
|
226
|
+
crm: {
|
|
227
|
+
next_actions: {
|
|
228
|
+
ownershipUsFallback: 'org_custom_reply'
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const config = resolveCrmNextActionRuleConfig(orgConfig)
|
|
233
|
+
expect(config.ownershipUsFallback).toBe('org_custom_reply')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('org override fires over default for same (state_key, ownership) pair', () => {
|
|
237
|
+
// Org remaps (discovery_replied, us) → 'org_reply'
|
|
238
|
+
const result = evaluateCrmDealNextAction(
|
|
239
|
+
{
|
|
240
|
+
stage_key: 'interested',
|
|
241
|
+
state_key: 'discovery_replied',
|
|
242
|
+
activity_log: [makeEvent(INBOUND, '2026-04-20T10:00:00Z')]
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
config: {
|
|
246
|
+
crm: {
|
|
247
|
+
next_actions: {
|
|
248
|
+
mappings: [{ stateKey: 'discovery_replied', ownership: 'us', actionKey: 'org_reply' }]
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
expect(result).toBe('org_reply')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('falls back to defaults when org override is empty object', () => {
|
|
258
|
+
const config = resolveCrmNextActionRuleConfig({ crm: { next_actions: {} } })
|
|
259
|
+
expect(config.mappings).toHaveLength(DEFAULT_CRM_NEXT_ACTION_RULE_CONFIG.mappings.length)
|
|
260
|
+
expect(config.ownershipUsFallback).toBe(DEFAULT_CRM_NEXT_ACTION_RULE_CONFIG.ownershipUsFallback)
|
|
261
|
+
})
|
|
262
|
+
})
|