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:
@@ -2,14 +2,16 @@ import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { getPortReminderConfig } from '@/lib/services/port-config';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||
@@ -540,6 +542,32 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
|
||||
|
||||
// Activity log entries in the last 7 days — surfaces "rep is engaged"
|
||||
// as a separate signal in the deal-health pulse beyond the coarse
|
||||
// dateLastContact bump.
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
|
||||
const [{ count: recentActivityCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestContactLog)
|
||||
.where(
|
||||
and(
|
||||
eq(interestContactLog.interestId, id),
|
||||
sql`${interestContactLog.occurredAt} >= ${sevenDaysAgo}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve the assignee's display name for the header chip — falling back
|
||||
// to the raw ID is fine if the user record is missing (deleted/disabled).
|
||||
let assignedToName: string | null = null;
|
||||
if (interest.assignedTo) {
|
||||
const [profile] = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, interest.assignedTo))
|
||||
.limit(1);
|
||||
assignedToName = profile?.displayName ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
...interest,
|
||||
clientName: clientRow?.fullName ?? null,
|
||||
@@ -554,6 +582,8 @@ export async function getInterestById(id: string, portId: string) {
|
||||
notesCount,
|
||||
recentNote: recentNote ?? null,
|
||||
activeReminderCount,
|
||||
assignedToName,
|
||||
recentActivityCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -586,12 +616,23 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
const resolvedReminderDays =
|
||||
interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null);
|
||||
|
||||
// Auto-assign to the port's default owner when the caller omits assignedTo.
|
||||
// Setting is stored as `{ userId: "..." }` so other surfaces can extend it
|
||||
// with round-robin / quota rules later without breaking this code path.
|
||||
let resolvedAssignedTo = interestData.assignedTo ?? null;
|
||||
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
|
||||
const defaultOwner = await getSetting('default_new_interest_owner', portId);
|
||||
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
||||
if (v?.userId) resolvedAssignedTo = v.userId;
|
||||
}
|
||||
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const [interest] = await tx
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
...interestData,
|
||||
assignedTo: resolvedAssignedTo,
|
||||
reminderEnabled: resolvedReminderEnabled,
|
||||
reminderDays: resolvedReminderDays,
|
||||
leadCategory: resolvedLeadCategory,
|
||||
@@ -734,6 +775,36 @@ export async function updateInterest(
|
||||
changedFields: Object.keys(diff),
|
||||
});
|
||||
|
||||
// Owner change → notify the new assignee. We skip self-reassign so a rep
|
||||
// re-claiming their own deal doesn't get a noise notification.
|
||||
if (
|
||||
'assignedTo' in data &&
|
||||
data.assignedTo &&
|
||||
data.assignedTo !== existing.assignedTo &&
|
||||
data.assignedTo !== meta.userId
|
||||
) {
|
||||
const [clientRow] = await db
|
||||
.select({ fullName: clients.fullName })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, existing.clientId))
|
||||
.limit(1);
|
||||
const clientLabel = clientRow?.fullName ?? 'a client';
|
||||
|
||||
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
||||
createNotification({
|
||||
portId,
|
||||
userId: data.assignedTo!,
|
||||
type: 'interest_assigned',
|
||||
title: 'New deal assigned to you',
|
||||
description: `${clientLabel} — ${existing.pipelineStage.replace(/_/g, ' ')}`,
|
||||
link: `/interests/${id}` as never,
|
||||
entityType: 'interest',
|
||||
entityId: id,
|
||||
dedupeKey: `interest_assigned:${id}:${data.assignedTo}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
@@ -753,14 +824,18 @@ export async function changeInterestStage(
|
||||
throw new NotFoundError('Interest');
|
||||
}
|
||||
|
||||
// Plan: yachtId required to leave stage=open
|
||||
if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) {
|
||||
throw new ValidationError('yachtId is required before leaving stage=open');
|
||||
// Plan: yachtId required to leave the initial enquiry stage
|
||||
if (
|
||||
existing.pipelineStage === 'enquiry' &&
|
||||
data.pipelineStage !== 'enquiry' &&
|
||||
!existing.yachtId
|
||||
) {
|
||||
throw new ValidationError('yachtId is required before leaving stage=enquiry');
|
||||
}
|
||||
|
||||
// Block egregious skips. The transition table allows reasonable forward
|
||||
// jumps (e.g. open → eoi_sent) while rejecting things like completed → open
|
||||
// or open → contract_signed. Same-stage no-ops are allowed.
|
||||
// jumps (e.g. enquiry → eoi) while rejecting things like contract → enquiry.
|
||||
// Same-stage no-ops are allowed.
|
||||
// Override (sales-rep manual fix) bypasses the table — the route handler
|
||||
// gates this on the `interests.override_stage` permission and requires
|
||||
// a reason, recorded in the audit log below.
|
||||
@@ -788,11 +863,13 @@ export async function changeInterestStage(
|
||||
// "deposit landed yesterday"); we still default to now when omitted.
|
||||
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
|
||||
const milestoneUpdates: Record<string, unknown> = {};
|
||||
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = milestoneDate;
|
||||
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = milestoneDate;
|
||||
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = milestoneDate;
|
||||
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = milestoneDate;
|
||||
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = milestoneDate;
|
||||
// For doc-bearing stages (eoi/reservation/contract) the milestone date is
|
||||
// owned by the doc-send/sign flow, not the stage move — these only fire
|
||||
// when the rep stamps a date manually via override.
|
||||
if (data.pipelineStage === 'eoi') milestoneUpdates.dateEoiSent = milestoneDate;
|
||||
if (data.pipelineStage === 'reservation') milestoneUpdates.dateReservationSigned = milestoneDate;
|
||||
if (data.pipelineStage === 'deposit_paid') milestoneUpdates.dateDepositReceived = milestoneDate;
|
||||
if (data.pipelineStage === 'contract') milestoneUpdates.dateContractSent = milestoneDate;
|
||||
if (Object.keys(milestoneUpdates).length > 0) {
|
||||
await db
|
||||
.update(interests)
|
||||
|
||||
Reference in New Issue
Block a user