@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,7 +1,7 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // Platform event member schemas (8 members — closed union, stays in @repo/core)
4
+ // Platform event member schemas (9 members — closed union, stays in @repo/core)
5
5
  //
6
6
  // Domain-specific events (reply_received, booking_nudge_sent, etc.) live in
7
7
  // external/elevasis/operations as CrmDomainActivityEventSchema.
@@ -66,8 +66,16 @@ const DealCreatedEventSchema = z.object({
66
66
  timestamp: z.string().datetime()
67
67
  })
68
68
 
69
+ const ActionFailedEventSchema = z.object({
70
+ type: z.literal('action_failed'),
71
+ timestamp: z.string().datetime(),
72
+ actionKey: z.string(),
73
+ errorMessage: z.string(),
74
+ payload: z.record(z.string(), z.unknown()).optional()
75
+ })
76
+
69
77
  // ---------------------------------------------------------------------------
70
- // Union — closed 8-member platform union
78
+ // Union — closed 9-member platform union
71
79
  // ---------------------------------------------------------------------------
72
80
 
73
81
  export const ActivityEventSchema = z.discriminatedUnion('type', [
@@ -78,7 +86,8 @@ export const ActivityEventSchema = z.discriminatedUnion('type', [
78
86
  ApprovalResolvedEventSchema,
79
87
  ApprovalStaleEventSchema,
80
88
  TaskCreatedEventSchema,
81
- DealCreatedEventSchema
89
+ DealCreatedEventSchema,
90
+ ActionFailedEventSchema
82
91
  ])
83
92
 
84
93
  export type ActivityEvent = z.infer<typeof ActivityEventSchema>
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { LEAD_GEN_PIPELINE_DEFINITIONS, LEAD_GEN_STAGE_CATALOG } from '../../organization-model/domains/sales'
2
+ import {
3
+ DEFAULT_CRM_PRIORITY_RULE_CONFIG,
4
+ LEAD_GEN_PIPELINE_DEFINITIONS,
5
+ LEAD_GEN_STAGE_CATALOG
6
+ } from '../../organization-model/domains/sales'
7
+ import { CrmPriorityOverrideSchema, evaluateCrmDealPriority, resolveCrmPriorityRuleConfig } from './crm-priority'
3
8
  import {
4
9
  AddCompaniesToListRequestSchema,
5
10
  AddContactsToListRequestSchema,
@@ -15,6 +20,7 @@ import {
15
20
  CreateDealTaskRequestSchema,
16
21
  CreateListRequestSchema,
17
22
  DealDetailResponseSchema,
23
+ DealListItemSchema,
18
24
  DealListResponseSchema,
19
25
  DealNoteResponseSchema,
20
26
  DealStageSchema,
@@ -44,6 +50,15 @@ import {
44
50
 
45
51
  const VALID_UUID = '00000000-0000-4000-8000-000000000001'
46
52
  const ISO_TS = '2026-04-27T12:34:56.000Z'
53
+ const PRIORITY = {
54
+ bucketKey: 'waiting' as const,
55
+ rank: 30,
56
+ label: 'Waiting',
57
+ color: 'blue',
58
+ reason: 'No immediate response or follow-up is due.',
59
+ latestActivityAt: ISO_TS,
60
+ nextActionAt: null
61
+ }
47
62
 
48
63
  // ---------------------------------------------------------------------------
49
64
  // DealStageSchema
@@ -414,10 +429,251 @@ describe('UpdateContactRequestSchema', () => {
414
429
  })
415
430
  })
416
431
 
432
+ // ---------------------------------------------------------------------------
433
+ // CRM priority override contract
434
+ // ---------------------------------------------------------------------------
435
+
436
+ describe('CrmPriorityOverrideSchema', () => {
437
+ it('accepts a valid partial organization override', () => {
438
+ const result = CrmPriorityOverrideSchema.safeParse({
439
+ enabled: true,
440
+ staleAfterDays: 10,
441
+ bucketOrder: ['needs_response', 'follow_up_due', 'stale', 'waiting', 'closed_low'],
442
+ buckets: {
443
+ needs_response: { label: 'Reply Now', color: 'red', rank: 5 },
444
+ waiting: { label: 'Pending' }
445
+ },
446
+ followUpAfterDaysByStateKey: {
447
+ discovery_link_sent: 2,
448
+ custom_state: 4
449
+ },
450
+ closedStageKeys: ['closed_won', 'closed_lost']
451
+ })
452
+
453
+ expect(result.success).toBe(true)
454
+ })
455
+
456
+ it('rejects needsResponseStateKeys and needsResponseActivityTypes (removed fields)', () => {
457
+ expect(CrmPriorityOverrideSchema.safeParse({ needsResponseStateKeys: ['x'] }).success).toBe(false)
458
+ expect(CrmPriorityOverrideSchema.safeParse({ needsResponseActivityTypes: ['x'] }).success).toBe(false)
459
+ })
460
+
461
+ it('accepts sparse partial overrides', () => {
462
+ const result = CrmPriorityOverrideSchema.safeParse({
463
+ staleAfterDays: 21,
464
+ buckets: {
465
+ stale: { color: 'yellow' }
466
+ }
467
+ })
468
+
469
+ expect(result.success).toBe(true)
470
+ })
471
+
472
+ it('rejects invalid unknown input shapes', () => {
473
+ expect(CrmPriorityOverrideSchema.safeParse('bad').success).toBe(false)
474
+ expect(CrmPriorityOverrideSchema.safeParse({ staleAfterDays: -1 }).success).toBe(false)
475
+ expect(CrmPriorityOverrideSchema.safeParse({ buckets: { invalid_bucket: { label: 'Bad' } } }).success).toBe(false)
476
+ })
477
+ })
478
+
479
+ describe('resolveCrmPriorityRuleConfig', () => {
480
+ it('merges valid organization config overrides with default rules', () => {
481
+ const resolved = resolveCrmPriorityRuleConfig({
482
+ crm: {
483
+ priority: {
484
+ staleAfterDays: 7,
485
+ bucketOrder: ['stale', 'needs_response'],
486
+ buckets: {
487
+ stale: { label: 'Dormant', color: 'yellow' },
488
+ needs_response: { rank: 3 }
489
+ },
490
+ followUpAfterDaysByStateKey: { discovery_link_sent: 1 },
491
+ closedStageKeys: ['won', 'lost']
492
+ }
493
+ }
494
+ })
495
+
496
+ expect(resolved.enabled).toBe(true)
497
+ expect(resolved.staleAfterDays).toBe(7)
498
+ expect(resolved.closedStageKeys).toEqual(['won', 'lost'])
499
+ expect(resolved.followUpAfterDaysByStateKey.discovery_link_sent).toBe(1)
500
+ expect(resolved.followUpAfterDaysByStateKey.reply_sent).toBe(
501
+ DEFAULT_CRM_PRIORITY_RULE_CONFIG.followUpAfterDaysByStateKey.reply_sent
502
+ )
503
+
504
+ const staleBucket = resolved.buckets.find((bucket) => bucket.bucketKey === 'stale')
505
+ expect(staleBucket).toMatchObject({ label: 'Dormant', color: 'yellow', rank: 10 })
506
+
507
+ const needsResponseBucket = resolved.buckets.find((bucket) => bucket.bucketKey === 'needs_response')
508
+ expect(needsResponseBucket?.rank).toBe(3)
509
+ })
510
+
511
+ it('falls back to defaults for missing or invalid input', () => {
512
+ expect(resolveCrmPriorityRuleConfig(undefined)).toMatchObject({
513
+ enabled: true,
514
+ staleAfterDays: DEFAULT_CRM_PRIORITY_RULE_CONFIG.staleAfterDays,
515
+ closedStageKeys: DEFAULT_CRM_PRIORITY_RULE_CONFIG.closedStageKeys
516
+ })
517
+
518
+ expect(resolveCrmPriorityRuleConfig({ staleAfterDays: 0 })).toMatchObject({
519
+ enabled: true,
520
+ staleAfterDays: DEFAULT_CRM_PRIORITY_RULE_CONFIG.staleAfterDays,
521
+ closedStageKeys: DEFAULT_CRM_PRIORITY_RULE_CONFIG.closedStageKeys
522
+ })
523
+ })
524
+ })
525
+
417
526
  // ---------------------------------------------------------------------------
418
527
  // Response schemas — smoke-level forward-compat checks
419
528
  // ---------------------------------------------------------------------------
420
529
 
530
+ describe('evaluateCrmDealPriority', () => {
531
+ const now = '2026-04-30T12:00:00.000Z'
532
+ const baseInput = {
533
+ stage_key: 'interested',
534
+ state_key: null,
535
+ activity_log: [],
536
+ updated_at: '2026-04-29T12:00:00.000Z',
537
+ created_at: '2026-04-01T12:00:00.000Z'
538
+ }
539
+
540
+ it('returns needs_response when the lead replied last (ownership us)', () => {
541
+ // A reply_received inbound event makes ownership === 'us' → needs_response
542
+ const priority = evaluateCrmDealPriority(
543
+ {
544
+ ...baseInput,
545
+ state_key: 'discovery_replied',
546
+ activity_log: [{ type: 'reply_received', timestamp: '2026-04-29T10:00:00.000Z' }]
547
+ },
548
+ { now }
549
+ )
550
+ expect(priority.bucketKey).toBe('needs_response')
551
+ expect(priority.rank).toBe(10)
552
+ expect(priority.reason).toBe('Lead replied last — we owe the next move.')
553
+ })
554
+
555
+ it('returns follow_up_due when configured follow-up window has elapsed', () => {
556
+ const priority = evaluateCrmDealPriority(
557
+ {
558
+ ...baseInput,
559
+ state_key: 'discovery_link_sent',
560
+ activity_log: [{ type: 'action_taken', timestamp: '2026-04-25T12:00:00.000Z', actionKey: 'send_link' }],
561
+ updated_at: '2026-04-25T12:00:00.000Z'
562
+ },
563
+ { now }
564
+ )
565
+
566
+ expect(priority.bucketKey).toBe('follow_up_due')
567
+ expect(priority.nextActionAt).toBe('2026-04-28T12:00:00.000Z')
568
+ })
569
+
570
+ it('returns waiting when the next action is still in the future', () => {
571
+ const priority = evaluateCrmDealPriority(
572
+ {
573
+ ...baseInput,
574
+ state_key: 'discovery_link_sent',
575
+ activity_log: [{ type: 'action_taken', timestamp: '2026-04-29T12:00:00.000Z', actionKey: 'send_link' }],
576
+ updated_at: '2026-04-29T12:00:00.000Z'
577
+ },
578
+ { now }
579
+ )
580
+
581
+ expect(priority.bucketKey).toBe('waiting')
582
+ expect(priority.nextActionAt).toBe('2026-05-02T12:00:00.000Z')
583
+ })
584
+
585
+ it('returns stale when there is no recent meaningful activity', () => {
586
+ const priority = evaluateCrmDealPriority(
587
+ { ...baseInput, updated_at: '2026-04-01T12:00:00.000Z', created_at: '2026-04-01T12:00:00.000Z' },
588
+ { now }
589
+ )
590
+
591
+ expect(priority.bucketKey).toBe('stale')
592
+ })
593
+
594
+ it('returns closed_low for closed stages', () => {
595
+ const priority = evaluateCrmDealPriority({ ...baseInput, stage_key: 'closed_lost' }, { now })
596
+ expect(priority.bucketKey).toBe('closed_low')
597
+ expect(priority.rank).toBe(50)
598
+ })
599
+
600
+ it('ignores malformed activity entries without throwing', () => {
601
+ const priority = evaluateCrmDealPriority(
602
+ {
603
+ ...baseInput,
604
+ activity_log: [null, 'bad', { type: 'reply_received', timestamp: 'not-a-date' }, { other: true }],
605
+ updated_at: '2026-04-29T12:00:00.000Z'
606
+ },
607
+ { now }
608
+ )
609
+
610
+ expect(priority.bucketKey).toBe('waiting')
611
+ expect(priority.latestActivityAt).toBe('2026-04-29T12:00:00.000Z')
612
+ })
613
+
614
+ it('returns a neutral waiting priority when CRM priority is disabled', () => {
615
+ const config = resolveCrmPriorityRuleConfig({
616
+ enabled: false,
617
+ buckets: {
618
+ waiting: { label: 'Priority Off', color: 'gray', rank: 999 }
619
+ }
620
+ })
621
+ const priority = evaluateCrmDealPriority(
622
+ {
623
+ ...baseInput,
624
+ state_key: 'discovery_replied',
625
+ activity_log: [{ type: 'reply_received', timestamp: '2026-04-30T10:00:00.000Z' }]
626
+ },
627
+ { config, now }
628
+ )
629
+
630
+ expect(priority).toMatchObject({
631
+ bucketKey: 'waiting',
632
+ label: 'Priority Off',
633
+ color: 'gray',
634
+ rank: 999,
635
+ reason: 'CRM priority evaluation is disabled.',
636
+ nextActionAt: null
637
+ })
638
+ })
639
+ })
640
+
641
+ describe('DealListItemSchema', () => {
642
+ it('accepts a deal with a derived priority object', () => {
643
+ const result = DealListItemSchema.safeParse({
644
+ id: VALID_UUID,
645
+ organization_id: VALID_UUID,
646
+ contact_id: null,
647
+ contact_email: 'test@example.com',
648
+ pipeline_key: 'crm',
649
+ stage_key: 'interested',
650
+ state_key: 'discovery_link_sent',
651
+ activity_log: [],
652
+ discovery_data: null,
653
+ discovery_submitted_at: null,
654
+ discovery_submitted_by: null,
655
+ proposal_data: null,
656
+ proposal_sent_at: null,
657
+ proposal_pdf_url: null,
658
+ signature_envelope_id: null,
659
+ source_list_id: null,
660
+ source_type: null,
661
+ initial_fee: null,
662
+ monthly_fee: null,
663
+ closed_lost_at: null,
664
+ closed_lost_reason: null,
665
+ created_at: ISO_TS,
666
+ updated_at: ISO_TS,
667
+ priority: PRIORITY,
668
+ ownership: null,
669
+ nextAction: null,
670
+ contact: null
671
+ })
672
+
673
+ expect(result.success).toBe(true)
674
+ })
675
+ })
676
+
421
677
  describe('DealDetailResponseSchema (forward-compat)', () => {
422
678
  const baseDeal = {
423
679
  id: VALID_UUID,
@@ -443,13 +699,39 @@ describe('DealDetailResponseSchema (forward-compat)', () => {
443
699
  closed_lost_reason: null,
444
700
  created_at: ISO_TS,
445
701
  updated_at: ISO_TS,
446
- contact: null
702
+ priority: PRIORITY,
703
+ ownership: null,
704
+ nextAction: null,
705
+ contact: null,
706
+ conversation: {
707
+ messages: []
708
+ }
447
709
  }
448
710
 
449
- it('accepts a deal with null contact', () => {
711
+ it('accepts a deal with null contact and an empty conversation', () => {
450
712
  expect(DealDetailResponseSchema.safeParse(baseDeal).success).toBe(true)
451
713
  })
452
714
 
715
+ it('accepts conversation messages with preview-derived bodies', () => {
716
+ const withConversation = {
717
+ ...baseDeal,
718
+ conversation: {
719
+ messages: [
720
+ {
721
+ id: 'message-1',
722
+ direction: 'inbound',
723
+ fromEmail: 'lead@example.com',
724
+ toEmail: 'sender@example.com',
725
+ subject: 'Re: quick thought',
726
+ body: 'Sure, send it over.',
727
+ sentAt: ISO_TS
728
+ }
729
+ ]
730
+ }
731
+ }
732
+ expect(DealDetailResponseSchema.safeParse(withConversation).success).toBe(true)
733
+ })
734
+
453
735
  it('accepts a deal with nested contact that has null company', () => {
454
736
  const withContact = {
455
737
  ...baseDeal,
@@ -668,6 +950,19 @@ describe('CreateListRequestSchema', () => {
668
950
  expect(CreateListRequestSchema.safeParse({ name: 'X', status: 'draft' }).success).toBe(true)
669
951
  })
670
952
 
953
+ it('accepts a known prospecting build template id', () => {
954
+ const result = CreateListRequestSchema.safeParse({
955
+ name: 'DTC Subscription Brands',
956
+ buildTemplateId: 'dtc-subscription-apollo-clickup'
957
+ })
958
+
959
+ expect(result.success).toBe(true)
960
+ })
961
+
962
+ it('rejects an unknown prospecting build template id', () => {
963
+ expect(CreateListRequestSchema.safeParse({ name: 'X', buildTemplateId: 'not-a-template' }).success).toBe(false)
964
+ })
965
+
671
966
  it('rejects an invalid status', () => {
672
967
  expect(CreateListRequestSchema.safeParse({ name: 'X', status: 'active' }).success).toBe(false)
673
968
  })
@@ -710,6 +1005,23 @@ describe('UpdateListRequestSchema', () => {
710
1005
  expect(UpdateListRequestSchema.safeParse({ batchIds: ['batch-1'] }).success).toBe(true)
711
1006
  })
712
1007
 
1008
+ it('accepts buildTemplateId when the change is explicitly confirmed', () => {
1009
+ expect(
1010
+ UpdateListRequestSchema.safeParse({
1011
+ buildTemplateId: 'dtc-subscription-apollo-clickup',
1012
+ confirmBuildTemplateChange: true
1013
+ }).success
1014
+ ).toBe(true)
1015
+ })
1016
+
1017
+ it('rejects buildTemplateId without explicit confirmation', () => {
1018
+ expect(
1019
+ UpdateListRequestSchema.safeParse({
1020
+ buildTemplateId: 'dtc-subscription-apollo-clickup'
1021
+ }).success
1022
+ ).toBe(false)
1023
+ })
1024
+
713
1025
  it('rejects unknown fields (strict mode)', () => {
714
1026
  expect(UpdateListRequestSchema.safeParse({ name: 'X', extra: 'bad' }).success).toBe(false)
715
1027
  })
@@ -1115,7 +1427,6 @@ describe('AcqListResponseSchema (forward-compat)', () => {
1115
1427
  organizationId: VALID_UUID,
1116
1428
  name: 'Test List',
1117
1429
  description: null,
1118
- type: 'standard',
1119
1430
  batchIds: [],
1120
1431
  instantlyCampaignId: null,
1121
1432
  status: 'draft' as const,