feat(pipeline): 9→7 stage refactor + v1.1 hardening wave

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>
This commit is contained in:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 deletions

View File

@@ -785,20 +785,29 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Trigger berth rules
void evaluateRule('eoi_sent', interest.id, portId, meta);
// Advance pipeline stage to eoi_sent (no-op if already further along).
void advanceStageIfBehind(interest.id, portId, 'eoi_sent', meta, 'EOI sent for signing');
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehind(interest.id, portId, 'eoi', meta, 'EOI sent for signing');
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
// G-C5: reservation agreements drive the contract_sent stage. The EOI
// and contract flows share `sendForSigning`, so we differentiate by
// documentType here rather than splitting the entry point.
// Reservation agreements drive the reservation stage; the contract
// pathway uses its own send call and stamps contractDocStatus.
if (doc.documentType === 'reservation_agreement') {
void advanceStageIfBehind(
interest.id,
portId,
'contract_sent',
'reservation',
meta,
'Reservation agreement sent',
);
await db
.update(interests)
.set({ reservationDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
}
}
@@ -888,17 +897,22 @@ export async function uploadSignedManually(
await db
.update(interests)
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
.set({
eoiStatus: 'signed',
eoiDocStatus: 'signed',
dateEoiSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
if (interest) {
void evaluateRule('eoi_signed', doc.interestId, portId, meta);
// Advance to eoi_signed (no-op if already past it).
// Stage stays at 'eoi' — sub-status badge flips to "signed".
void advanceStageIfBehind(
doc.interestId,
portId,
'eoi_signed',
'eoi',
meta,
'Signed EOI uploaded manually',
);
@@ -1412,30 +1426,6 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
ipAddress: '0.0.0.0',
userAgent: 'webhook',
});
// G-C5: reservation agreement signing-complete → contract_signed.
// Fired here (not below in the eoi-only branch) so contract pipeline
// tracks reality the same way EOIs do via the eoi_signed advance.
if (doc.documentType === 'reservation_agreement' && doc.interestId) {
const systemMeta: AuditMeta = {
userId: 'system',
portId: doc.portId,
ipAddress: '0.0.0.0',
userAgent: 'webhook',
};
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'contract_signed',
systemMeta,
'Reservation agreement signed',
);
// Dynamic import mirrors the eoi_signed pattern below to avoid the
// berth-rules-engine module-cycle risk during cold-start.
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
}
} catch (err) {
// Distinguish "we lost the concurrent race" from a real failure —
// the loser of the SELECT FOR UPDATE re-check should clean up its
@@ -1486,7 +1476,12 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
await db
.update(interests)
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
.set({
eoiStatus: 'signed',
eoiDocStatus: 'signed',
dateEoiSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
if (interest) {
@@ -1497,30 +1492,89 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
userAgent: 'webhook',
};
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times
// (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the
// stage. advanceStageIfBehind handles the pipeline guard internally, but
// evaluateRule has no idempotency - skip it if the interest is already at
// eoi_signed or beyond to prevent duplicate berth-rule side effects.
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple
// times. evaluateRule has no idempotency — skip when the interest is
// already past the EOI stage so the berth-rule side effect runs once.
const currentStageIdx = PIPELINE_STAGES.indexOf(
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
if (currentStageIdx < eoiSignedIdx) {
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
if (currentStageIdx <= eoiIdx) {
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
}
// Advance to eoi_signed (no-op if interest already past it).
// Stage stays at 'eoi' — sub-status flips to signed.
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'eoi_signed',
'eoi',
systemMeta,
'EOI signed via Documenso',
);
}
}
// Update interest if reservation_agreement type — kept out of the
// signed-PDF try/catch above so a Documenso PDF-download failure doesn't
// also lose the sub-status stamp (which the rep can see immediately on
// the interest detail page).
if (doc.interestId && doc.documentType === 'reservation_agreement') {
const systemMeta: AuditMeta = {
userId: 'system',
portId: doc.portId,
ipAddress: '0.0.0.0',
userAgent: 'webhook',
};
await db
.update(interests)
.set({
reservationDocStatus: 'signed',
dateReservationSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'reservation',
systemMeta,
'Reservation agreement signed',
);
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
}
// Update interest if contract type. Outcome flip to 'won' is a separate
// explicit decision so reps can record a contract as signed without
// prematurely closing the deal.
if (doc.interestId && doc.documentType === 'contract') {
const systemMeta: AuditMeta = {
userId: 'system',
portId: doc.portId,
ipAddress: '0.0.0.0',
userAgent: 'webhook',
};
await db
.update(interests)
.set({
contractDocStatus: 'signed',
dateContractSigned: new Date(),
updatedAt: new Date(),
})
.where(eq(interests.id, doc.interestId));
void advanceStageIfBehind(
doc.interestId,
doc.portId,
'contract',
systemMeta,
'Contract signed via Documenso',
);
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
);
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',