@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +60 -0
  2. package/dist/index.js +198 -1
  3. package/dist/organization-model/index.d.ts +60 -0
  4. package/dist/organization-model/index.js +198 -1
  5. package/dist/test-utils/index.d.ts +399 -363
  6. package/dist/test-utils/index.js +198 -1
  7. package/package.json +3 -3
  8. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +444 -309
  9. package/src/business/acquisition/activity-events.ts +12 -3
  10. package/src/business/acquisition/api-schemas.test.ts +315 -4
  11. package/src/business/acquisition/api-schemas.ts +140 -17
  12. package/src/business/acquisition/build-templates.ts +44 -0
  13. package/src/business/acquisition/crm-next-action.test.ts +262 -0
  14. package/src/business/acquisition/crm-next-action.ts +220 -0
  15. package/src/business/acquisition/crm-priority.test.ts +216 -0
  16. package/src/business/acquisition/crm-priority.ts +349 -0
  17. package/src/business/acquisition/crm-state-actions.test.ts +12 -21
  18. package/src/business/acquisition/deal-ownership.test.ts +351 -0
  19. package/src/business/acquisition/deal-ownership.ts +120 -0
  20. package/src/business/acquisition/derive-actions.test.ts +101 -37
  21. package/src/business/acquisition/derive-actions.ts +49 -24
  22. package/src/business/acquisition/index.ts +163 -149
  23. package/src/business/acquisition/types.ts +48 -4
  24. package/src/execution/engine/index.ts +4 -3
  25. package/src/execution/engine/tools/lead-service-types.ts +68 -51
  26. package/src/execution/engine/tools/platform/acquisition/list-tools.ts +6 -5
  27. package/src/execution/engine/tools/platform/acquisition/types.ts +3 -1
  28. package/src/execution/engine/tools/registry.ts +4 -3
  29. package/src/execution/engine/tools/tool-maps.ts +821 -816
  30. package/src/organization-model/domains/prospecting.ts +204 -1
  31. package/src/organization-model/domains/sales.test.ts +218 -0
  32. package/src/organization-model/domains/sales.ts +558 -366
  33. package/src/organization-model/types.ts +2 -2
  34. package/src/platform/constants/versions.ts +1 -1
  35. package/src/reference/_generated/contracts.md +444 -309
  36. 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
- // joined relation
170
- contact: DealContactSummarySchema.nullable()
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
- * useDealDetail returns DealDetail which is typed as DealListItem.
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((data) => data.name !== undefined || data.description !== undefined || data.batchIds !== undefined, {
455
- message: 'At least one field (name, description, or batchIds) must be provided'
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: z.record(z.string(), z.unknown()),
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
- * `done` = count of members/companies where `(processing_state->>'<key>')::boolean` is true.
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
- done: z.number().int().min(0),
552
- total: z.number().int().min(0)
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 via COUNT(*) FILTER over processing_state flags (Decision #4).
558
- * `byStage` keys are driven by the list's pipeline_config.stages[].key.
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
+ })