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

@@ -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)