Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.
Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
three doc-status columns, two documenso-id columns, and
date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
interest_qualifications (per-interest state), payments (deposit /
balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
the new stage + doc-status + outcome shape.
Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).
v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
the contact-log compose dialog (useVoiceTranscription hook).
- C: berth-rules-engine wraps state writes in pg_advisory_xact_lock
with an idempotent re-read; emits rule_evaluated audit traces.
- D: Documenso webhook: reservation/contract sub-status stamping
moved out of the PDF-download try-block so a download failure
no longer swallows the stamp. New integration test coverage.
- E: /admin/qualification-criteria CRUD page + admin component.
- F: default_new_interest_owner exposed in System Settings.
- G: recentActivityCount + active_engagement deal-pulse signal
surfaced as a chip on interests + hot-deals card.
- H: interest_assigned notification on assignedTo change (skips
self-assign, uses a dedupe key).
Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.
Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { computeDealHealth } from '@/lib/services/deal-health';
|
|
|
|
const daysAgo = (n: number): string => new Date(Date.now() - n * 86_400_000).toISOString();
|
|
|
|
describe('computeDealHealth', () => {
|
|
it('returns baseline 50 + warm pulse when no signals fire', () => {
|
|
const r = computeDealHealth({ pipelineStage: 'enquiry' });
|
|
expect(r.score).toBe(50);
|
|
expect(r.pulse).toBe('warm');
|
|
expect(r.signals).toEqual([]);
|
|
});
|
|
|
|
it('returns neutral baseline when interest is closed/won', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'contract',
|
|
outcome: 'won',
|
|
dateLastContact: daysAgo(2),
|
|
});
|
|
expect(r.score).toBe(50);
|
|
expect(r.signals).toEqual([]);
|
|
});
|
|
|
|
it('returns neutral baseline when interest is archived', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'eoi',
|
|
archivedAt: daysAgo(10),
|
|
dateLastContact: daysAgo(2),
|
|
});
|
|
expect(r.signals).toEqual([]);
|
|
});
|
|
|
|
it('+20 for contact in last 7 days', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'enquiry',
|
|
dateLastContact: daysAgo(3),
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'contact_recent');
|
|
expect(sig?.delta).toBe(20);
|
|
expect(r.score).toBeGreaterThanOrEqual(70);
|
|
expect(r.pulse).toBe('hot');
|
|
});
|
|
|
|
it('+10 for contact within 14 days', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'enquiry',
|
|
dateLastContact: daysAgo(10),
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'contact_warm');
|
|
expect(sig?.delta).toBe(10);
|
|
});
|
|
|
|
it('-15 when no contact in 30+ days', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'qualified',
|
|
dateLastContact: daysAgo(40),
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'contact_stale');
|
|
expect(sig?.delta).toBe(-15);
|
|
});
|
|
|
|
it('-10 when EOI sent >14d ago and still unsigned', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'eoi',
|
|
dateEoiSent: daysAgo(20),
|
|
eoiDocStatus: 'sent',
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'eoi_awaiting');
|
|
expect(sig?.delta).toBe(-10);
|
|
});
|
|
|
|
it('does NOT penalize EOI awaiting when sub-status is signed', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'eoi',
|
|
dateEoiSent: daysAgo(20),
|
|
eoiDocStatus: 'signed',
|
|
});
|
|
expect(r.signals.find((s) => s.id === 'eoi_awaiting')).toBeUndefined();
|
|
});
|
|
|
|
it('-10 when reservation signed >21d ago but no deposit', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'reservation',
|
|
dateReservationSigned: daysAgo(25),
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'deposit_pending');
|
|
expect(sig?.delta).toBe(-10);
|
|
});
|
|
|
|
it('does NOT penalize deposit_pending when deposit already received', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'deposit_paid',
|
|
dateReservationSigned: daysAgo(25),
|
|
dateDepositReceived: daysAgo(2),
|
|
});
|
|
expect(r.signals.find((s) => s.id === 'deposit_pending')).toBeUndefined();
|
|
});
|
|
|
|
it('stage progress bonus caps at +30', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'contract',
|
|
contractDocStatus: 'signed',
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'stage_progress');
|
|
expect(sig?.delta).toBeLessThanOrEqual(30);
|
|
expect(sig?.delta).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('-10 for deals stuck pre-EOI for 30+ days', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'enquiry',
|
|
dateFirstContact: daysAgo(45),
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'stuck_top_funnel');
|
|
expect(sig?.delta).toBe(-10);
|
|
});
|
|
|
|
it('clamps score to [0, 100]', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'contract',
|
|
dateLastContact: daysAgo(2),
|
|
contractDocStatus: 'signed',
|
|
});
|
|
expect(r.score).toBeLessThanOrEqual(100);
|
|
expect(r.score).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('rates a fresh contract-signed deal as hot', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'contract',
|
|
dateLastContact: daysAgo(1),
|
|
contractDocStatus: 'signed',
|
|
});
|
|
expect(r.pulse).toBe('hot');
|
|
});
|
|
|
|
it('rates a stalled mid-funnel deal with no recent contact as cold', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'qualified',
|
|
dateFirstContact: daysAgo(60),
|
|
dateLastContact: daysAgo(45),
|
|
});
|
|
expect(r.pulse).toBe('cold');
|
|
});
|
|
|
|
it('+5 active_engagement when recentActivityCount >= 1', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'enquiry',
|
|
recentActivityCount: 3,
|
|
});
|
|
const sig = r.signals.find((s) => s.id === 'active_engagement');
|
|
expect(sig?.delta).toBe(5);
|
|
});
|
|
|
|
it('skips active_engagement when recentActivityCount is null/undefined', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'enquiry',
|
|
recentActivityCount: null,
|
|
});
|
|
expect(r.signals.find((s) => s.id === 'active_engagement')).toBeUndefined();
|
|
});
|
|
|
|
it('skips active_engagement when recentActivityCount is 0', () => {
|
|
const r = computeDealHealth({
|
|
pipelineStage: 'enquiry',
|
|
recentActivityCount: 0,
|
|
});
|
|
expect(r.signals.find((s) => s.id === 'active_engagement')).toBeUndefined();
|
|
});
|
|
});
|