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:
@@ -46,7 +46,12 @@ export type AuditAction =
|
||||
| 'webhook_dead_letter'
|
||||
| 'webhook_retried'
|
||||
| 'job_failed'
|
||||
| 'cron_run';
|
||||
| 'cron_run'
|
||||
// Berth-rule decision trace: emitted by the rules engine on every
|
||||
// evaluateRule() call so admins can debug "why did this fire / not fire"
|
||||
// without reading server logs. Distinct from the actual `update` audit
|
||||
// row the auto-applied path emits when it mutates berth status.
|
||||
| 'rule_evaluated';
|
||||
|
||||
/**
|
||||
* Common shape passed to service functions so they can stamp audit logs and
|
||||
|
||||
@@ -1,97 +1,102 @@
|
||||
// ─── Pipeline Stages ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// 7 canonical stages (one optional). Document-signing stages (EOI, Reservation,
|
||||
// Contract) collapse "Sent + Signed" into one stage; the sub-status lives on
|
||||
// per-stage doc-status columns (`eoi_doc_status`, etc.) and is rendered as a
|
||||
// badge inside the kanban card.
|
||||
//
|
||||
// `nurturing` is built but disabled-by-default for ports that don't have
|
||||
// supply constraints (e.g. Port Nimara pre-launch). Admins enable it per port.
|
||||
|
||||
export const PIPELINE_STAGES = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
'enquiry',
|
||||
'qualified',
|
||||
'nurturing',
|
||||
'eoi',
|
||||
'reservation',
|
||||
'deposit_paid',
|
||||
'contract',
|
||||
] as const;
|
||||
|
||||
export type PipelineStage = (typeof PIPELINE_STAGES)[number];
|
||||
|
||||
/**
|
||||
* Sub-status values for document-signing stages (EOI, Reservation, Contract).
|
||||
* Stored on per-stage columns `eoi_doc_status` / `reservation_doc_status` /
|
||||
* `contract_doc_status` on the interests table.
|
||||
*/
|
||||
export const DOC_STATUSES = ['pending', 'sent', 'signed', 'declined', 'voided'] as const;
|
||||
export type DocStatus = (typeof DOC_STATUSES)[number];
|
||||
|
||||
export const STAGE_LABELS: Record<PipelineStage, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Comms',
|
||||
eoi_sent: 'EOI Sent',
|
||||
eoi_signed: 'EOI Signed',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract_sent: 'Contract Sent',
|
||||
contract_signed: 'Contract Signed',
|
||||
completed: 'Completed',
|
||||
enquiry: 'New Enquiry',
|
||||
qualified: 'Qualified',
|
||||
nurturing: 'Nurturing',
|
||||
eoi: 'EOI',
|
||||
reservation: 'Reservation',
|
||||
deposit_paid: 'Deposit Paid',
|
||||
contract: 'Contract',
|
||||
};
|
||||
|
||||
// Compact labels for cramped contexts (mobile chart axes, dense tables).
|
||||
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details',
|
||||
in_communication: 'Comms',
|
||||
eoi_sent: 'EOI →',
|
||||
eoi_signed: 'EOI ✓',
|
||||
deposit_10pct: 'Dep.',
|
||||
contract_sent: 'Ctr →',
|
||||
contract_signed: 'Ctr ✓',
|
||||
completed: 'Done',
|
||||
enquiry: 'Enquiry',
|
||||
qualified: 'Qual.',
|
||||
nurturing: 'Nurt.',
|
||||
eoi: 'EOI',
|
||||
reservation: 'Resv.',
|
||||
deposit_paid: 'Dep.',
|
||||
contract: 'Contract',
|
||||
};
|
||||
|
||||
export const STAGE_BADGE: Record<PipelineStage, string> = {
|
||||
open: 'bg-slate-100 text-slate-700',
|
||||
details_sent: 'bg-blue-100 text-blue-700',
|
||||
in_communication: 'bg-sky-100 text-sky-700',
|
||||
eoi_sent: 'bg-indigo-100 text-indigo-700',
|
||||
eoi_signed: 'bg-amber-100 text-amber-700',
|
||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
||||
contract_sent: 'bg-yellow-100 text-yellow-700',
|
||||
contract_signed: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-emerald-100 text-emerald-700',
|
||||
enquiry: 'bg-slate-100 text-slate-700',
|
||||
qualified: 'bg-blue-100 text-blue-700',
|
||||
nurturing: 'bg-purple-100 text-purple-700',
|
||||
eoi: 'bg-indigo-100 text-indigo-700',
|
||||
reservation: 'bg-amber-100 text-amber-700',
|
||||
deposit_paid: 'bg-orange-100 text-orange-700',
|
||||
contract: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
export const STAGE_DOT: Record<PipelineStage, string> = {
|
||||
open: 'bg-slate-400',
|
||||
details_sent: 'bg-blue-500',
|
||||
in_communication: 'bg-sky-500',
|
||||
eoi_sent: 'bg-indigo-500',
|
||||
eoi_signed: 'bg-amber-500',
|
||||
deposit_10pct: 'bg-orange-500',
|
||||
contract_sent: 'bg-yellow-500',
|
||||
contract_signed: 'bg-green-500',
|
||||
completed: 'bg-emerald-500',
|
||||
enquiry: 'bg-slate-400',
|
||||
qualified: 'bg-blue-500',
|
||||
nurturing: 'bg-purple-500',
|
||||
eoi: 'bg-indigo-500',
|
||||
reservation: 'bg-amber-500',
|
||||
deposit_paid: 'bg-orange-500',
|
||||
contract: 'bg-green-500',
|
||||
};
|
||||
|
||||
// Default revenue-forecast probability weights per stage (0–1).
|
||||
// Editable per port via settings (`pipeline_weights`); these are the fallbacks.
|
||||
export const STAGE_WEIGHTS: Record<PipelineStage, number> = {
|
||||
open: 0.05,
|
||||
details_sent: 0.1,
|
||||
in_communication: 0.2,
|
||||
eoi_sent: 0.4,
|
||||
eoi_signed: 0.6,
|
||||
deposit_10pct: 0.75,
|
||||
contract_sent: 0.85,
|
||||
contract_signed: 0.95,
|
||||
completed: 1.0,
|
||||
enquiry: 0.05,
|
||||
qualified: 0.15,
|
||||
nurturing: 0.15,
|
||||
eoi: 0.4,
|
||||
reservation: 0.7,
|
||||
deposit_paid: 0.85,
|
||||
contract: 0.95,
|
||||
};
|
||||
|
||||
// Allowed transitions out of each stage. Used by changeInterestStage to guard
|
||||
// against accidental skips (e.g. dragging a card from Completed back to Open,
|
||||
// or jumping Open straight to Completed). Forward moves of 1-2 stages are
|
||||
// permitted; backward moves are limited to the immediate predecessor unless
|
||||
// the lifecycle (EOI/contract chain) needs an explicit rewind.
|
||||
/**
|
||||
* Allowed transitions out of each stage. Skip-aheads (e.g. enquiry →
|
||||
* deposit_paid) are gated by the explicit `override:true` path in
|
||||
* `changeInterestStage` and surface as a backfill banner on the interest.
|
||||
*
|
||||
* Nurturing is bidirectional with qualified (deal pauses → reopens),
|
||||
* and can re-enter the EOI path when supply opens up.
|
||||
*/
|
||||
export const STAGE_TRANSITIONS: Record<PipelineStage, readonly PipelineStage[]> = {
|
||||
open: ['details_sent', 'in_communication', 'eoi_sent', 'eoi_signed'],
|
||||
details_sent: ['open', 'in_communication', 'eoi_sent', 'eoi_signed'],
|
||||
in_communication: ['open', 'details_sent', 'eoi_sent', 'eoi_signed'],
|
||||
eoi_sent: ['in_communication', 'eoi_signed', 'deposit_10pct'],
|
||||
eoi_signed: ['eoi_sent', 'deposit_10pct', 'contract_sent', 'contract_signed'],
|
||||
deposit_10pct: ['eoi_signed', 'contract_sent', 'contract_signed'],
|
||||
contract_sent: ['eoi_signed', 'deposit_10pct', 'contract_signed'],
|
||||
contract_signed: ['contract_sent', 'deposit_10pct', 'completed'],
|
||||
completed: ['contract_signed'],
|
||||
enquiry: ['qualified', 'eoi'],
|
||||
qualified: ['enquiry', 'nurturing', 'eoi'],
|
||||
nurturing: ['qualified', 'eoi'],
|
||||
eoi: ['qualified', 'reservation', 'deposit_paid'],
|
||||
reservation: ['eoi', 'deposit_paid'],
|
||||
deposit_paid: ['reservation', 'contract'],
|
||||
contract: ['deposit_paid'],
|
||||
};
|
||||
|
||||
export function canTransitionStage(from: string, to: string): boolean {
|
||||
@@ -102,7 +107,7 @@ export function canTransitionStage(from: string, to: string): boolean {
|
||||
}
|
||||
|
||||
export function safeStage(value: string | null | undefined): PipelineStage {
|
||||
return PIPELINE_STAGES.includes(value as PipelineStage) ? (value as PipelineStage) : 'open';
|
||||
return PIPELINE_STAGES.includes(value as PipelineStage) ? (value as PipelineStage) : 'enquiry';
|
||||
}
|
||||
|
||||
export function stageLabel(stage: string | null | undefined): string {
|
||||
|
||||
139
src/lib/db/migrations/0062_pipeline_refactor.sql
Normal file
139
src/lib/db/migrations/0062_pipeline_refactor.sql
Normal file
@@ -0,0 +1,139 @@
|
||||
-- 0062_pipeline_refactor.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Pipeline refactor: 7 canonical stages, doc sub-status columns, qualification
|
||||
-- criteria, payment records, assigned_to ownership, expected deposit amount.
|
||||
-- Dummy-data only at this point; legacy-to-new migration for prod cutover is
|
||||
-- a separate one-shot tool to be written when production data is ready.
|
||||
|
||||
-- ─── interests: new columns ────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE interests
|
||||
ADD COLUMN IF NOT EXISTS assigned_to text REFERENCES user_profiles(user_id),
|
||||
ADD COLUMN IF NOT EXISTS deposit_expected_amount numeric,
|
||||
ADD COLUMN IF NOT EXISTS deposit_expected_currency text DEFAULT 'EUR',
|
||||
ADD COLUMN IF NOT EXISTS eoi_doc_status text,
|
||||
ADD COLUMN IF NOT EXISTS reservation_doc_status text,
|
||||
ADD COLUMN IF NOT EXISTS contract_doc_status text,
|
||||
ADD COLUMN IF NOT EXISTS reservation_documenso_id text,
|
||||
ADD COLUMN IF NOT EXISTS contract_documenso_id text,
|
||||
ADD COLUMN IF NOT EXISTS date_reservation_signed timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_interests_assigned_to ON interests (assigned_to);
|
||||
|
||||
-- ─── stage value migration (collapse Sent/Signed pairs) ────────────────────
|
||||
-- Dummy-data only — destructive UPDATE is safe.
|
||||
|
||||
UPDATE interests SET pipeline_stage = 'enquiry'
|
||||
WHERE pipeline_stage IN ('open', 'details_sent', 'in_communication');
|
||||
|
||||
UPDATE interests
|
||||
SET pipeline_stage = 'eoi', eoi_doc_status = 'sent'
|
||||
WHERE pipeline_stage = 'eoi_sent';
|
||||
|
||||
UPDATE interests
|
||||
SET pipeline_stage = 'eoi', eoi_doc_status = 'signed'
|
||||
WHERE pipeline_stage = 'eoi_signed';
|
||||
|
||||
UPDATE interests SET pipeline_stage = 'deposit_paid'
|
||||
WHERE pipeline_stage = 'deposit_10pct';
|
||||
|
||||
UPDATE interests
|
||||
SET pipeline_stage = 'contract', contract_doc_status = 'sent'
|
||||
WHERE pipeline_stage = 'contract_sent';
|
||||
|
||||
UPDATE interests
|
||||
SET pipeline_stage = 'contract', contract_doc_status = 'signed'
|
||||
WHERE pipeline_stage = 'contract_signed';
|
||||
|
||||
-- `completed` collapses into contract+signed+won (the old terminal stage
|
||||
-- always implied outcome=won; outcome field carries that forward).
|
||||
UPDATE interests
|
||||
SET pipeline_stage = 'contract',
|
||||
contract_doc_status = 'signed',
|
||||
outcome = COALESCE(outcome, 'won'),
|
||||
outcome_at = COALESCE(outcome_at, updated_at)
|
||||
WHERE pipeline_stage = 'completed';
|
||||
|
||||
-- ─── Qualification criteria (per-port, admin-configurable) ──────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qualification_criteria (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
key text NOT NULL,
|
||||
label text NOT NULL,
|
||||
description text,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
display_order int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (port_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qualification_criteria_port
|
||||
ON qualification_criteria (port_id);
|
||||
|
||||
-- Seed the default criteria for every existing port. Port admins can later
|
||||
-- enable/disable or add port-specific entries.
|
||||
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
|
||||
SELECT p.id, 'dimensions', 'Dimensions confirmed',
|
||||
'We know the vessel''s length, width, and draft.', true, 1
|
||||
FROM ports p
|
||||
ON CONFLICT (port_id, key) DO NOTHING;
|
||||
|
||||
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
|
||||
SELECT p.id, 'intent', 'Intent confirmed',
|
||||
'Client has explicitly confirmed they want a berth at this marina.', true, 2
|
||||
FROM ports p
|
||||
ON CONFLICT (port_id, key) DO NOTHING;
|
||||
|
||||
-- These are built but disabled — admins enable per port when relevant.
|
||||
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
|
||||
SELECT p.id, 'signatory', 'Buyer signatory confirmed',
|
||||
'We know who is authorized to sign the EOI on the buyer side (owner / lawyer / company rep).',
|
||||
false, 3
|
||||
FROM ports p
|
||||
ON CONFLICT (port_id, key) DO NOTHING;
|
||||
|
||||
INSERT INTO qualification_criteria (port_id, key, label, description, enabled, display_order)
|
||||
SELECT p.id, 'timeline', 'Move-in timeline confirmed',
|
||||
'Client has indicated when they want to start using the berth.',
|
||||
false, 4
|
||||
FROM ports p
|
||||
ON CONFLICT (port_id, key) DO NOTHING;
|
||||
|
||||
-- ─── Per-interest qualification state ──────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS interest_qualifications (
|
||||
interest_id text NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
|
||||
criterion_key text NOT NULL,
|
||||
confirmed boolean NOT NULL DEFAULT false,
|
||||
confirmed_at timestamptz,
|
||||
confirmed_by text,
|
||||
notes text,
|
||||
PRIMARY KEY (interest_id, criterion_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_interest_qualifications_interest
|
||||
ON interest_qualifications (interest_id);
|
||||
|
||||
-- ─── Payment records (no invoice generation) ───────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
interest_id text NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
payment_type text NOT NULL, -- 'deposit' | 'balance' | 'refund' | 'other'
|
||||
amount numeric NOT NULL,
|
||||
currency text NOT NULL DEFAULT 'EUR',
|
||||
received_at timestamptz NOT NULL,
|
||||
receipt_file_id text REFERENCES files(id),
|
||||
notes text,
|
||||
recorded_by text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_interest ON payments (interest_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_client ON payments (client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_port ON payments (port_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_type ON payments (port_id, payment_type);
|
||||
14
src/lib/db/migrations/0063_contact_log_voice_template.sql
Normal file
14
src/lib/db/migrations/0063_contact_log_voice_template.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 0063_contact_log_voice_template.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Pipeline-refactor follow-up. The contact-log captures sales-rep
|
||||
-- interactions; the v1 UX adds two new columns:
|
||||
-- - voice_transcript: raw Web Speech API output, kept separate from the
|
||||
-- rep-polished `summary` so we can re-render the
|
||||
-- transcript verbatim if the rep wants to revisit it.
|
||||
-- - template_used: which of the 3 quick-template buttons was tapped, if
|
||||
-- any. Surfaces in reports so admins can see how reps
|
||||
-- actually log activity (call/visit/email).
|
||||
|
||||
ALTER TABLE interest_contact_log
|
||||
ADD COLUMN IF NOT EXISTS voice_transcript text,
|
||||
ADD COLUMN IF NOT EXISTS template_used text;
|
||||
@@ -68,5 +68,8 @@ export * from './website-submissions';
|
||||
// Pre-EOI supplemental form tokens
|
||||
export * from './supplemental-forms';
|
||||
|
||||
// Pipeline refactor — qualification criteria, payment records
|
||||
export * from './pipeline';
|
||||
|
||||
// Relations (must come last - references all tables)
|
||||
export * from './relations';
|
||||
|
||||
@@ -15,7 +15,8 @@ import { clients } from './clients';
|
||||
import { berths } from './berths';
|
||||
import { yachts } from './yachts';
|
||||
|
||||
// Pipeline stages: open, details_sent, in_communication, eoi_sent, eoi_signed, deposit_10pct, contract_sent, contract_signed, completed
|
||||
// Pipeline stages: enquiry, qualified, nurturing, eoi, reservation, deposit_paid, contract
|
||||
// (doc sub-status carried on eoi_doc_status / reservation_doc_status / contract_doc_status)
|
||||
|
||||
export const interests = pgTable(
|
||||
'interests',
|
||||
@@ -30,7 +31,19 @@ export const interests = pgTable(
|
||||
.notNull()
|
||||
.references(() => clients.id),
|
||||
yachtId: text('yacht_id').references(() => yachts.id, { onDelete: 'set null' }),
|
||||
pipelineStage: text('pipeline_stage').notNull().default('open'),
|
||||
/** Who owns this deal. Auto-assigned on create from system_settings
|
||||
* `default_new_interest_owner`; reassignable via the interest header. */
|
||||
assignedTo: text('assigned_to'),
|
||||
pipelineStage: text('pipeline_stage').notNull().default('enquiry'),
|
||||
/** Sub-status for the doc-signing stages. NULL while the deal hasn't
|
||||
* reached the stage yet; 'pending' | 'sent' | 'signed' | 'declined' | 'voided'. */
|
||||
eoiDocStatus: text('eoi_doc_status'),
|
||||
reservationDocStatus: text('reservation_doc_status'),
|
||||
contractDocStatus: text('contract_doc_status'),
|
||||
/** Documenso IDs per document type. EOI uses the existing `documensoId`
|
||||
* for backward compat with the template-generate path. */
|
||||
reservationDocumensoId: text('reservation_documenso_id'),
|
||||
contractDocumensoId: text('contract_documenso_id'),
|
||||
leadCategory: text('lead_category'), // general_interest, specific_qualified, hot_lead
|
||||
source: text('source'), // website, manual, referral, broker
|
||||
eoiStatus: text('eoi_status'), // null, waiting_for_signatures, signed, expired
|
||||
@@ -38,10 +51,16 @@ export const interests = pgTable(
|
||||
contractStatus: text('contract_status'),
|
||||
depositStatus: text('deposit_status'),
|
||||
reservationStatus: text('reservation_status'),
|
||||
/** Agreed deposit amount captured at reservation-agreement time. Lets
|
||||
* the payments running-total decide when the deposit is "fully paid"
|
||||
* and the stage advances automatically. */
|
||||
depositExpectedAmount: numeric('deposit_expected_amount'),
|
||||
depositExpectedCurrency: text('deposit_expected_currency').default('EUR'),
|
||||
dateFirstContact: timestamp('date_first_contact', { withTimezone: true }),
|
||||
dateLastContact: timestamp('date_last_contact', { withTimezone: true }),
|
||||
dateEoiSent: timestamp('date_eoi_sent', { withTimezone: true }),
|
||||
dateEoiSigned: timestamp('date_eoi_signed', { withTimezone: true }),
|
||||
dateReservationSigned: timestamp('date_reservation_signed', { withTimezone: true }),
|
||||
dateContractSent: timestamp('date_contract_sent', { withTimezone: true }),
|
||||
dateContractSigned: timestamp('date_contract_signed', { withTimezone: true }),
|
||||
dateDepositReceived: timestamp('date_deposit_received', { withTimezone: true }),
|
||||
@@ -86,6 +105,7 @@ export const interests = pgTable(
|
||||
.on(table.portId)
|
||||
.where(sql`${table.archivedAt} IS NULL`),
|
||||
index('idx_interests_outcome').on(table.portId, table.outcome),
|
||||
index('idx_interests_assigned_to').on(table.assignedTo),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -231,6 +231,13 @@ export const interestContactLog = pgTable(
|
||||
direction: text('direction').notNull().default('outbound'),
|
||||
/** Short free text — "Discussed yacht size, asked about tax structure". */
|
||||
summary: text('summary').notNull(),
|
||||
/** Raw Web Speech API transcript captured at log time, kept separate
|
||||
* from the rep-polished `summary` so the original utterance survives
|
||||
* edits to the summary text. NULL when the rep typed manually. */
|
||||
voiceTranscript: text('voice_transcript'),
|
||||
/** Which of the 3 quick-template buttons was tapped on the log modal
|
||||
* ('call' | 'visit' | 'email'). NULL when the rep filled in freeform. */
|
||||
templateUsed: text('template_used'),
|
||||
/** Optional. When set, a reminder is auto-created pointing back to
|
||||
* the interest for follow-up. Stored as the original choice so the
|
||||
* UI can re-render it; the actual reminder lives in `reminders`. */
|
||||
|
||||
125
src/lib/db/schema/pipeline.ts
Normal file
125
src/lib/db/schema/pipeline.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pipeline-refactor tables — per-port qualification criteria, per-interest
|
||||
* qualification state, and payment records (no invoice generation).
|
||||
*
|
||||
* See migrations/0062_pipeline_refactor.sql.
|
||||
*/
|
||||
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
boolean,
|
||||
integer,
|
||||
numeric,
|
||||
timestamp,
|
||||
index,
|
||||
primaryKey,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ports } from './ports';
|
||||
import { interests } from './interests';
|
||||
import { clients } from './clients';
|
||||
import { files } from './documents';
|
||||
|
||||
/**
|
||||
* Per-port qualification criteria. Admin-configurable: enable/disable,
|
||||
* rename labels, reorder. The default seed is 2 enabled (dimensions +
|
||||
* intent) and 2 disabled (signatory + timeline) per port.
|
||||
*/
|
||||
export const qualificationCriteria = pgTable(
|
||||
'qualification_criteria',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
/** Stable key for code references. e.g. 'dimensions', 'intent'. */
|
||||
key: text('key').notNull(),
|
||||
/** Display label shown on the qualification checklist UI. */
|
||||
label: text('label').notNull(),
|
||||
/** Optional short description shown as helper text under the checkbox. */
|
||||
description: text('description'),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
displayOrder: integer('display_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_qualification_criteria_port').on(table.portId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-interest qualification state. Composite PK (interest_id, criterion_key)
|
||||
* lets the same key exist across ports without conflicts and gives O(1)
|
||||
* lookup for the "all enabled criteria confirmed" check.
|
||||
*/
|
||||
export const interestQualifications = pgTable(
|
||||
'interest_qualifications',
|
||||
{
|
||||
interestId: text('interest_id')
|
||||
.notNull()
|
||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||
criterionKey: text('criterion_key').notNull(),
|
||||
confirmed: boolean('confirmed').notNull().default(false),
|
||||
confirmedAt: timestamp('confirmed_at', { withTimezone: true }),
|
||||
confirmedBy: text('confirmed_by'),
|
||||
notes: text('notes'),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.interestId, table.criterionKey] }),
|
||||
index('idx_interest_qualifications_interest').on(table.interestId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Payment records. The CRM does NOT generate invoices — clients pay banks
|
||||
* directly. We record that money was received (or refunded) with an
|
||||
* optional uploaded receipt for audit purposes.
|
||||
*
|
||||
* The "deposit_paid" stage auto-advances when SUM(payments where type=deposit)
|
||||
* for an interest reaches the `interests.depositExpectedAmount`.
|
||||
*/
|
||||
export const payments = pgTable(
|
||||
'payments',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
interestId: text('interest_id')
|
||||
.notNull()
|
||||
.references(() => interests.id, { onDelete: 'cascade' }),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||
/** 'deposit' | 'balance' | 'refund' | 'other' — `refund` rows carry
|
||||
* negative amounts so the running total nets out correctly. */
|
||||
paymentType: text('payment_type').notNull(),
|
||||
amount: numeric('amount').notNull(),
|
||||
currency: text('currency').notNull().default('EUR'),
|
||||
receivedAt: timestamp('received_at', { withTimezone: true }).notNull(),
|
||||
/** Optional uploaded receipt PDF. The UI warns reps that recording
|
||||
* without a receipt may make later verification harder, but doesn't
|
||||
* block the save. */
|
||||
receiptFileId: text('receipt_file_id').references(() => files.id),
|
||||
notes: text('notes'),
|
||||
recordedBy: text('recorded_by').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_payments_interest').on(table.interestId),
|
||||
index('idx_payments_client').on(table.clientId),
|
||||
index('idx_payments_port').on(table.portId),
|
||||
index('idx_payments_type').on(table.portId, table.paymentType),
|
||||
],
|
||||
);
|
||||
|
||||
export type QualificationCriterion = typeof qualificationCriteria.$inferSelect;
|
||||
export type NewQualificationCriterion = typeof qualificationCriteria.$inferInsert;
|
||||
export type InterestQualification = typeof interestQualifications.$inferSelect;
|
||||
export type NewInterestQualification = typeof interestQualifications.$inferInsert;
|
||||
export type Payment = typeof payments.$inferSelect;
|
||||
export type NewPayment = typeof payments.$inferInsert;
|
||||
@@ -828,15 +828,13 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
berthIdx: number | null;
|
||||
yachtIdx: number | null;
|
||||
pipelineStage:
|
||||
| 'open'
|
||||
| 'details_sent'
|
||||
| 'in_communication'
|
||||
| 'eoi_sent'
|
||||
| 'eoi_signed'
|
||||
| 'deposit_10pct'
|
||||
| 'contract_sent'
|
||||
| 'contract_signed'
|
||||
| 'completed';
|
||||
| 'enquiry'
|
||||
| 'qualified'
|
||||
| 'nurturing'
|
||||
| 'eoi'
|
||||
| 'reservation'
|
||||
| 'deposit_paid'
|
||||
| 'contract';
|
||||
leadCategory: 'general_interest' | 'specific_qualified' | 'hot_lead';
|
||||
source: 'website' | 'manual' | 'referral' | 'broker';
|
||||
daysAgoFirst: number;
|
||||
@@ -846,7 +844,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 0,
|
||||
berthIdx: 0,
|
||||
yachtIdx: 0,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'website',
|
||||
daysAgoFirst: 5,
|
||||
@@ -855,7 +853,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 1,
|
||||
berthIdx: 1,
|
||||
yachtIdx: 1,
|
||||
pipelineStage: 'details_sent',
|
||||
pipelineStage: 'qualified',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'website',
|
||||
daysAgoFirst: 12,
|
||||
@@ -864,7 +862,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 2,
|
||||
berthIdx: 2,
|
||||
yachtIdx: 2,
|
||||
pipelineStage: 'in_communication',
|
||||
pipelineStage: 'qualified',
|
||||
leadCategory: 'specific_qualified',
|
||||
source: 'referral',
|
||||
daysAgoFirst: 25,
|
||||
@@ -873,7 +871,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 3,
|
||||
berthIdx: 3,
|
||||
yachtIdx: 6,
|
||||
pipelineStage: 'eoi_sent',
|
||||
pipelineStage: 'eoi',
|
||||
leadCategory: 'specific_qualified',
|
||||
source: 'referral',
|
||||
daysAgoFirst: 40,
|
||||
@@ -882,7 +880,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 4,
|
||||
berthIdx: 4,
|
||||
yachtIdx: null,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'broker',
|
||||
daysAgoFirst: 8,
|
||||
@@ -891,7 +889,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 5,
|
||||
berthIdx: 5,
|
||||
yachtIdx: 3,
|
||||
pipelineStage: 'eoi_signed',
|
||||
pipelineStage: 'eoi',
|
||||
leadCategory: 'hot_lead',
|
||||
source: 'manual',
|
||||
daysAgoFirst: 55,
|
||||
@@ -900,7 +898,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 6,
|
||||
berthIdx: 6,
|
||||
yachtIdx: 4,
|
||||
pipelineStage: 'deposit_10pct',
|
||||
pipelineStage: 'deposit_paid',
|
||||
leadCategory: 'hot_lead',
|
||||
source: 'referral',
|
||||
daysAgoFirst: 70,
|
||||
@@ -909,7 +907,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 0,
|
||||
berthIdx: 7,
|
||||
yachtIdx: 5,
|
||||
pipelineStage: 'contract_signed',
|
||||
pipelineStage: 'contract',
|
||||
leadCategory: 'hot_lead',
|
||||
source: 'broker',
|
||||
daysAgoFirst: 90,
|
||||
@@ -918,7 +916,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 1,
|
||||
berthIdx: 10,
|
||||
yachtIdx: 1,
|
||||
pipelineStage: 'completed',
|
||||
pipelineStage: 'contract',
|
||||
leadCategory: 'hot_lead',
|
||||
source: 'referral',
|
||||
daysAgoFirst: 240,
|
||||
@@ -927,7 +925,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 7,
|
||||
berthIdx: 11,
|
||||
yachtIdx: 11,
|
||||
pipelineStage: 'completed',
|
||||
pipelineStage: 'contract',
|
||||
leadCategory: 'hot_lead',
|
||||
source: 'manual',
|
||||
daysAgoFirst: 320,
|
||||
@@ -936,7 +934,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 2,
|
||||
berthIdx: null,
|
||||
yachtIdx: null,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'website',
|
||||
daysAgoFirst: 3,
|
||||
@@ -945,7 +943,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 3,
|
||||
berthIdx: 8,
|
||||
yachtIdx: 6,
|
||||
pipelineStage: 'in_communication',
|
||||
pipelineStage: 'qualified',
|
||||
leadCategory: 'specific_qualified',
|
||||
source: 'website',
|
||||
daysAgoFirst: 18,
|
||||
@@ -954,7 +952,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 5,
|
||||
berthIdx: null,
|
||||
yachtIdx: 3,
|
||||
pipelineStage: 'details_sent',
|
||||
pipelineStage: 'qualified',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'referral',
|
||||
daysAgoFirst: 10,
|
||||
@@ -964,7 +962,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 4,
|
||||
berthIdx: 2,
|
||||
yachtIdx: null,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'website',
|
||||
daysAgoFirst: 180,
|
||||
@@ -974,7 +972,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
||||
clientIdx: 6,
|
||||
berthIdx: 9,
|
||||
yachtIdx: 4,
|
||||
pipelineStage: 'eoi_sent',
|
||||
pipelineStage: 'eoi',
|
||||
leadCategory: 'specific_qualified',
|
||||
source: 'broker',
|
||||
daysAgoFirst: 45,
|
||||
|
||||
@@ -100,9 +100,14 @@ interface SyntheticClientSpec {
|
||||
postalCode: string;
|
||||
/** Pipeline stage of the (single) interest. Omit for archived-only clients. */
|
||||
stage?: PipelineStage;
|
||||
/** Sub-status badges for the doc-signing stages (eoi / reservation / contract).
|
||||
* Only meaningful when stage matches; otherwise null/undefined. */
|
||||
eoiDocStatus?: 'sent' | 'signed';
|
||||
reservationDocStatus?: 'sent' | 'signed';
|
||||
contractDocStatus?: 'sent' | 'signed';
|
||||
/** Index into BERTH_SNAPSHOT for the primary linked berth. */
|
||||
berthIdx?: number;
|
||||
/** Mark interest as won/lost when stage = completed. */
|
||||
/** Mark interest as won/lost when stage = contract+signed. */
|
||||
outcome?: 'won' | 'lost_unqualified' | 'lost_no_response';
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
@@ -145,7 +150,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'London',
|
||||
street: '14 Cheyne Walk',
|
||||
postalCode: 'SW3 5RA',
|
||||
stage: 'open',
|
||||
stage: 'enquiry',
|
||||
source: 'website',
|
||||
createdDaysAgo: 4,
|
||||
// Open stage: no berth link yet
|
||||
@@ -159,7 +164,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Miami',
|
||||
street: '880 Brickell Bay Drive',
|
||||
postalCode: '33131',
|
||||
stage: 'details_sent',
|
||||
stage: 'enquiry',
|
||||
berthIdx: 0,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 12,
|
||||
@@ -173,7 +178,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Palma de Mallorca',
|
||||
street: 'Carrer de Sant Magí 23',
|
||||
postalCode: '07013',
|
||||
stage: 'in_communication',
|
||||
stage: 'qualified',
|
||||
berthIdx: 5,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 28,
|
||||
@@ -187,7 +192,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Genoa',
|
||||
street: 'Via XX Settembre 47',
|
||||
postalCode: '16121',
|
||||
stage: 'eoi_sent',
|
||||
stage: 'eoi',
|
||||
eoiDocStatus: 'sent',
|
||||
berthIdx: 6,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 45,
|
||||
@@ -201,7 +207,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Nice',
|
||||
street: '8 Promenade des Anglais',
|
||||
postalCode: '06000',
|
||||
stage: 'eoi_signed',
|
||||
stage: 'eoi',
|
||||
eoiDocStatus: 'signed',
|
||||
berthIdx: 7,
|
||||
source: 'website',
|
||||
createdDaysAgo: 72,
|
||||
@@ -215,7 +222,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Athens',
|
||||
street: 'Vouliagmenis Avenue 142',
|
||||
postalCode: '16674',
|
||||
stage: 'deposit_10pct',
|
||||
stage: 'deposit_paid',
|
||||
berthIdx: 8,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 95,
|
||||
@@ -229,7 +236,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Dublin',
|
||||
street: '12 Merrion Square North',
|
||||
postalCode: 'D02 E2X3',
|
||||
stage: 'contract_sent',
|
||||
stage: 'contract',
|
||||
contractDocStatus: 'sent',
|
||||
berthIdx: 9,
|
||||
source: 'manual',
|
||||
createdDaysAgo: 118,
|
||||
@@ -243,7 +251,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Lisbon',
|
||||
street: 'Rua Garrett 88',
|
||||
postalCode: '1200-205',
|
||||
stage: 'contract_signed',
|
||||
stage: 'contract',
|
||||
contractDocStatus: 'signed',
|
||||
berthIdx: 4,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 156,
|
||||
@@ -257,7 +266,8 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Panama City',
|
||||
street: 'Calle 50, Torre Banistmo Piso 18',
|
||||
postalCode: '0816',
|
||||
stage: 'completed',
|
||||
stage: 'contract',
|
||||
contractDocStatus: 'signed',
|
||||
berthIdx: 10,
|
||||
outcome: 'won',
|
||||
source: 'referral',
|
||||
@@ -272,7 +282,7 @@ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
city: 'Hamburg',
|
||||
street: 'Alsterufer 28',
|
||||
postalCode: '20354',
|
||||
stage: 'completed',
|
||||
stage: 'enquiry',
|
||||
berthIdx: 1,
|
||||
outcome: 'lost_unqualified',
|
||||
source: 'website',
|
||||
@@ -552,19 +562,21 @@ export async function seedSyntheticPortData(
|
||||
|
||||
// ── 6. Berth status overrides for linked moorings ───────────────────────
|
||||
// Match the dossier classification to the berth's pipeline stage.
|
||||
// For under_offer-wave clients (eoi_sent → contract_sent), force the
|
||||
// berth to under_offer. For completed-won, mark the berth sold.
|
||||
// Sold = contract+signed+won. Under offer = active berth-bearing stages
|
||||
// (eoi / reservation / deposit_paid / contract-not-yet-won).
|
||||
const stageToBerthStatus = (
|
||||
stage: PipelineStage | undefined,
|
||||
spec: SyntheticClientSpec,
|
||||
): 'available' | 'under_offer' | 'sold' | null => {
|
||||
const stage = spec.stage;
|
||||
if (!stage) return null;
|
||||
if (stage === 'completed') return 'sold';
|
||||
if (stage === 'contract' && spec.contractDocStatus === 'signed' && spec.outcome === 'won') {
|
||||
return 'sold';
|
||||
}
|
||||
if (
|
||||
stage === 'eoi_sent' ||
|
||||
stage === 'eoi_signed' ||
|
||||
stage === 'deposit_10pct' ||
|
||||
stage === 'contract_sent' ||
|
||||
stage === 'contract_signed'
|
||||
stage === 'eoi' ||
|
||||
stage === 'reservation' ||
|
||||
stage === 'deposit_paid' ||
|
||||
stage === 'contract'
|
||||
) {
|
||||
return 'under_offer';
|
||||
}
|
||||
@@ -573,7 +585,7 @@ export async function seedSyntheticPortData(
|
||||
|
||||
for (const spec of PIPELINE_CLIENTS) {
|
||||
if (spec.berthIdx === undefined) continue;
|
||||
const newStatus = stageToBerthStatus(spec.stage);
|
||||
const newStatus = stageToBerthStatus(spec);
|
||||
if (!newStatus) continue;
|
||||
const berthId = berthRows[spec.berthIdx]!.id;
|
||||
await tx.update(berths).set({ status: newStatus }).where(eq(berths.id, berthId));
|
||||
@@ -584,20 +596,33 @@ export async function seedSyntheticPortData(
|
||||
for (const spec of PIPELINE_CLIENTS) {
|
||||
if (!spec.stage) continue;
|
||||
const clientId = idByTag.get(spec.tag)!;
|
||||
// Derive deal age from the (stage, doc-sub-status) pair so a
|
||||
// contract+signed+won record looks older than a brand-new enquiry.
|
||||
const stageDaysAgoMap: Record<PipelineStage, number> = {
|
||||
open: 1,
|
||||
details_sent: 5,
|
||||
in_communication: 10,
|
||||
eoi_sent: 20,
|
||||
eoi_signed: 35,
|
||||
deposit_10pct: 60,
|
||||
contract_sent: 80,
|
||||
contract_signed: 110,
|
||||
completed: spec.outcome === 'won' ? 200 : 60,
|
||||
enquiry: 5,
|
||||
qualified: 10,
|
||||
nurturing: 30,
|
||||
eoi: spec.eoiDocStatus === 'signed' ? 35 : 20,
|
||||
reservation: 50,
|
||||
deposit_paid: 60,
|
||||
contract: spec.outcome === 'won' ? 200 : spec.contractDocStatus === 'signed' ? 110 : 80,
|
||||
};
|
||||
const ageDays = stageDaysAgoMap[spec.stage];
|
||||
const yachtId = spec.tag === 'completed-won' ? charterYachtRow[0]!.id : null;
|
||||
|
||||
// Stage-progression flags so the date_* timestamps cascade correctly.
|
||||
// Anything past "eoi+sent" implies the EOI was at least sent.
|
||||
const eoiReached =
|
||||
spec.stage === 'eoi' ||
|
||||
spec.stage === 'reservation' ||
|
||||
spec.stage === 'deposit_paid' ||
|
||||
spec.stage === 'contract';
|
||||
const eoiSigned =
|
||||
(spec.stage === 'eoi' && spec.eoiDocStatus === 'signed') ||
|
||||
spec.stage === 'reservation' ||
|
||||
spec.stage === 'deposit_paid' ||
|
||||
spec.stage === 'contract';
|
||||
|
||||
const [intRow] = await tx
|
||||
.insert(interests)
|
||||
.values({
|
||||
@@ -605,40 +630,24 @@ export async function seedSyntheticPortData(
|
||||
clientId,
|
||||
yachtId,
|
||||
pipelineStage: spec.stage,
|
||||
eoiDocStatus: spec.eoiDocStatus ?? (eoiSigned ? 'signed' : null),
|
||||
reservationDocStatus: spec.reservationDocStatus ?? null,
|
||||
contractDocStatus: spec.contractDocStatus ?? null,
|
||||
leadCategory:
|
||||
spec.stage === 'open'
|
||||
spec.stage === 'enquiry'
|
||||
? 'general_interest'
|
||||
: spec.stage === 'details_sent' || spec.stage === 'in_communication'
|
||||
: spec.stage === 'qualified' || spec.stage === 'nurturing'
|
||||
? 'specific_qualified'
|
||||
: 'hot_lead',
|
||||
source: 'manual' as const,
|
||||
dateFirstContact: daysAgo(ageDays),
|
||||
dateLastContact: daysAgo(Math.max(0, ageDays - 2)),
|
||||
dateEoiSent:
|
||||
spec.stage === 'eoi_sent' ||
|
||||
spec.stage === 'eoi_signed' ||
|
||||
spec.stage === 'deposit_10pct' ||
|
||||
spec.stage === 'contract_sent' ||
|
||||
spec.stage === 'contract_signed' ||
|
||||
spec.stage === 'completed'
|
||||
? daysAgo(Math.max(0, ageDays - 5))
|
||||
: null,
|
||||
dateEoiSigned:
|
||||
spec.stage === 'eoi_signed' ||
|
||||
spec.stage === 'deposit_10pct' ||
|
||||
spec.stage === 'contract_sent' ||
|
||||
spec.stage === 'contract_signed' ||
|
||||
spec.stage === 'completed'
|
||||
? daysAgo(Math.max(0, ageDays - 10))
|
||||
: null,
|
||||
dateEoiSent: eoiReached ? daysAgo(Math.max(0, ageDays - 5)) : null,
|
||||
dateEoiSigned: eoiSigned ? daysAgo(Math.max(0, ageDays - 10)) : null,
|
||||
eoiStatus:
|
||||
spec.stage === 'eoi_sent'
|
||||
spec.stage === 'eoi' && spec.eoiDocStatus === 'sent'
|
||||
? 'waiting_for_signatures'
|
||||
: spec.stage === 'eoi_signed' ||
|
||||
spec.stage === 'deposit_10pct' ||
|
||||
spec.stage === 'contract_sent' ||
|
||||
spec.stage === 'contract_signed' ||
|
||||
spec.stage === 'completed'
|
||||
: eoiSigned
|
||||
? 'signed'
|
||||
: null,
|
||||
outcome: spec.outcome ?? null,
|
||||
@@ -656,7 +665,7 @@ export async function seedSyntheticPortData(
|
||||
berthId,
|
||||
isPrimary: true,
|
||||
isSpecificInterest: true,
|
||||
isInEoiBundle: spec.stage !== 'open' && spec.stage !== 'details_sent',
|
||||
isInEoiBundle: spec.stage !== 'enquiry' && spec.stage !== 'qualified',
|
||||
addedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
}
|
||||
@@ -672,7 +681,7 @@ export async function seedSyntheticPortData(
|
||||
portId,
|
||||
clientId: carlaId,
|
||||
yachtId: null,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
leadCategory: 'general_interest',
|
||||
source: 'website' as const,
|
||||
dateFirstContact: daysAgo(2),
|
||||
|
||||
@@ -187,9 +187,9 @@ export async function seedWideSyntheticPortData(
|
||||
yachtId: null,
|
||||
pipelineStage: stage,
|
||||
leadCategory:
|
||||
stage === 'open'
|
||||
stage === 'enquiry'
|
||||
? 'general_interest'
|
||||
: stage === 'details_sent' || stage === 'in_communication'
|
||||
: stage === 'qualified' || stage === 'nurturing'
|
||||
? 'specific_qualified'
|
||||
: 'hot_lead',
|
||||
source: sourceChoice.source,
|
||||
|
||||
@@ -25,8 +25,9 @@ export async function withTransaction<T>(callback: (tx: typeof db) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes a record by setting `archived_at` to now.
|
||||
* The table must have an `archived_at` column.
|
||||
* Soft-deletes a record by setting `archivedAt` to now.
|
||||
* The table must have an `archivedAt` JS property mapping to the
|
||||
* `archived_at` column.
|
||||
*
|
||||
* @example
|
||||
* await softDelete(clients, clients.id, clientId);
|
||||
@@ -36,15 +37,22 @@ export async function softDelete<TTable extends PgTable>(
|
||||
idColumn: PgColumn,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
// Drizzle's `.set()` API expects the JS property name (`archivedAt`),
|
||||
// not the snake-case column name. The previous version passed
|
||||
// `{ archived_at: ... }` which silently produced an empty SET clause
|
||||
// (Drizzle skipped the unknown property) → `UPDATE ... where ...`
|
||||
// syntax error from Postgres. Caught by QA: archive an interest
|
||||
// with a berth attached → 500. Same fix in restore() below.
|
||||
await db
|
||||
.update(table)
|
||||
.set({ archived_at: sql`now()` } as Record<string, unknown>)
|
||||
.set({ archivedAt: sql`now()` } as Record<string, unknown>)
|
||||
.where(eq(idColumn, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a soft-deleted record by clearing `archived_at`.
|
||||
* The table must have an `archived_at` column.
|
||||
* Restores a soft-deleted record by clearing `archivedAt`.
|
||||
* The table must have an `archivedAt` JS property mapping to the
|
||||
* `archived_at` column.
|
||||
*
|
||||
* @example
|
||||
* await restore(clients, clients.id, clientId);
|
||||
@@ -56,6 +64,6 @@ export async function restore<TTable extends PgTable>(
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(table)
|
||||
.set({ archived_at: null } as Record<string, unknown>)
|
||||
.set({ archivedAt: null } as Record<string, unknown>)
|
||||
.where(eq(idColumn, id));
|
||||
}
|
||||
|
||||
@@ -51,16 +51,13 @@ export interface InterestSummaryPdfProps {
|
||||
}
|
||||
|
||||
const STAGE_TONE: Record<string, BadgeTone> = {
|
||||
open: 'neutral',
|
||||
details_sent: 'neutral',
|
||||
in_communication: 'neutral',
|
||||
eoi_sent: 'accent',
|
||||
eoi_signed: 'accent',
|
||||
deposit_10pct: 'warning',
|
||||
contract_sent: 'warning',
|
||||
contract_signed: 'success',
|
||||
completed: 'success',
|
||||
cancelled: 'danger',
|
||||
enquiry: 'neutral',
|
||||
qualified: 'neutral',
|
||||
nurturing: 'neutral',
|
||||
eoi: 'accent',
|
||||
reservation: 'warning',
|
||||
deposit_paid: 'warning',
|
||||
contract: 'success',
|
||||
};
|
||||
|
||||
function fmt(d: Date | string | null | undefined): string {
|
||||
|
||||
@@ -16,15 +16,13 @@ export interface PipelineReportPdfProps {
|
||||
}
|
||||
|
||||
const FUNNEL_STAGES = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
'enquiry',
|
||||
'qualified',
|
||||
'nurturing',
|
||||
'eoi',
|
||||
'reservation',
|
||||
'deposit_paid',
|
||||
'contract',
|
||||
];
|
||||
|
||||
interface TopInterestRow {
|
||||
|
||||
@@ -19,15 +19,13 @@ export interface RevenueReportPdfProps {
|
||||
}
|
||||
|
||||
const STAGE_ORDER = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
'enquiry',
|
||||
'qualified',
|
||||
'nurturing',
|
||||
'eoi',
|
||||
'reservation',
|
||||
'deposit_paid',
|
||||
'contract',
|
||||
];
|
||||
|
||||
function fmtAmount(n: number, currency: string): string {
|
||||
|
||||
@@ -133,7 +133,8 @@ export async function computePipelineFunnel(
|
||||
.groupBy(interests.pipelineStage);
|
||||
|
||||
const counts = new Map(stageRows.map((r) => [r.stage, r.count]));
|
||||
const top = counts.get('open') ?? 0;
|
||||
// First stage in the canonical order anchors the conversion percentage.
|
||||
const top = counts.get(PIPELINE_STAGES[0]) ?? 0;
|
||||
|
||||
const stages = PIPELINE_STAGES.map((stage) => {
|
||||
const count = counts.get(stage) ?? 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
@@ -7,6 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -94,45 +95,105 @@ export async function evaluateRule(
|
||||
const rulesConfig = await getRulesConfig(portId);
|
||||
const rule = rulesConfig[trigger];
|
||||
|
||||
// Decision-trace audit: ALWAYS record what we decided to do (or not do),
|
||||
// including the rule mode, so admins can debug "why didn't this fire?" /
|
||||
// "why did this fire" without grepping server logs. Tagged `berth_rule_decision`
|
||||
// so it's distinct from the actual mutation audit row below.
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'rule_evaluated',
|
||||
entityType: 'berth',
|
||||
entityId: targetBerthId,
|
||||
metadata: {
|
||||
type: 'berth_rule_decision',
|
||||
trigger,
|
||||
mode: rule.mode,
|
||||
targetStatus: rule.targetStatus,
|
||||
interestId,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
if (rule.mode === 'off') {
|
||||
return { action: 'none' };
|
||||
}
|
||||
|
||||
if (rule.mode === 'auto') {
|
||||
await db
|
||||
.update(berths)
|
||||
.set({
|
||||
status: rule.targetStatus,
|
||||
statusLastChangedBy: meta.userId,
|
||||
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
||||
statusLastModified: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||
// Concurrency hardening: wrap the read-then-write in a transaction with a
|
||||
// berth-scoped advisory lock so two concurrent webhook retries can't both
|
||||
// commit the same status flip (which produces duplicate audit rows + a
|
||||
// double socket emit). Also short-circuit when the target status is
|
||||
// already in place — re-writing 'sold'→'sold' is technically harmless
|
||||
// but pollutes the audit trail and the socket stream.
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// pg_advisory_xact_lock takes a single bigint. We hash port+berth into
|
||||
// a stable 32-bit slot. The lock auto-releases at transaction end so
|
||||
// there's no risk of a stuck lock if the handler crashes mid-write.
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtext(${`berth-rule:${portId}:${targetBerthId}`}))`,
|
||||
);
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'berth',
|
||||
entityId: targetBerthId,
|
||||
newValue: { status: rule.targetStatus },
|
||||
metadata: { type: 'berth_rule_auto', trigger, interestId },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
// Re-read inside the lock so we observe the post-lock state, not the
|
||||
// pre-lock snapshot. If the prior contender already moved status to
|
||||
// our target, we're idempotent and bail.
|
||||
const [current] = await tx
|
||||
.select({ status: berths.status })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||
|
||||
if (!current) return { changed: false as const };
|
||||
if (current.status === rule.targetStatus) {
|
||||
// Idempotent re-fire. We already audited the decision above; nothing
|
||||
// more to do here.
|
||||
logger.debug(
|
||||
{ trigger, targetBerthId, portId, status: current.status },
|
||||
'Berth-rule auto: target status already set, skipping duplicate write',
|
||||
);
|
||||
return { changed: false as const };
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(berths)
|
||||
.set({
|
||||
status: rule.targetStatus,
|
||||
statusLastChangedBy: meta.userId,
|
||||
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
||||
statusLastModified: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||
|
||||
return { changed: true as const, previousStatus: current.status };
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
|
||||
berthId: targetBerthId,
|
||||
newStatus: rule.targetStatus,
|
||||
triggeredBy: meta.userId,
|
||||
trigger,
|
||||
});
|
||||
if (result.changed) {
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'berth',
|
||||
entityId: targetBerthId,
|
||||
oldValue: { status: result.previousStatus },
|
||||
newValue: { status: rule.targetStatus },
|
||||
metadata: { type: 'berth_rule_auto', trigger, interestId },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
|
||||
berthId: targetBerthId,
|
||||
newStatus: rule.targetStatus,
|
||||
triggeredBy: meta.userId,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
return { action: 'applied', newStatus: rule.targetStatus };
|
||||
}
|
||||
|
||||
// suggest mode
|
||||
// suggest mode — the decision-trace audit above already records the suggestion.
|
||||
return {
|
||||
action: 'suggested',
|
||||
newStatus: rule.targetStatus,
|
||||
|
||||
@@ -34,10 +34,8 @@ import type { PipelineStage } from '@/lib/constants';
|
||||
* a reason for these clients.
|
||||
*/
|
||||
export const HIGH_STAKES_STAGES: ReadonlySet<PipelineStage> = new Set<PipelineStage>([
|
||||
'deposit_10pct',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
'deposit_paid',
|
||||
'contract',
|
||||
]);
|
||||
|
||||
export type ArchiveStakeLevel = 'low' | 'high';
|
||||
@@ -433,18 +431,18 @@ export async function getClientArchiveDossier(
|
||||
}
|
||||
|
||||
// Stage rank used to pick the "highest" high-stakes stage when surfacing
|
||||
// the warning copy. Higher = more committed.
|
||||
// the warning copy. Higher = more committed. Doc sub-status is folded back
|
||||
// in via the caller (treating eoi+signed as past nurturing, contract+signed
|
||||
// as the apex).
|
||||
function rankStage(s: PipelineStage): number {
|
||||
switch (s) {
|
||||
case 'completed':
|
||||
return 5;
|
||||
case 'contract_signed':
|
||||
case 'contract':
|
||||
return 4;
|
||||
case 'contract_sent':
|
||||
case 'deposit_paid':
|
||||
return 3;
|
||||
case 'deposit_10pct':
|
||||
case 'reservation':
|
||||
return 2;
|
||||
case 'eoi_signed':
|
||||
case 'eoi':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
|
||||
@@ -345,17 +345,28 @@ export async function uploadDocumentForSigning(
|
||||
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
|
||||
// Pipeline transition: contract_sent stage when contract or
|
||||
// reservation_agreement goes out for signing. eoi_sent is reserved
|
||||
// for the template-driven EOI flow. No berth-rules trigger here —
|
||||
// the rules engine fires on `contract_signed` (webhook-driven).
|
||||
// Pipeline transition: contract / reservation custom-upload goes out
|
||||
// for signing. Stamps the matching doc-status sub-state so the badge
|
||||
// flips to 'sent' immediately. EOI stage is reserved for the template
|
||||
// pathway and stamped from documents.service.ts. No berth-rules trigger
|
||||
// here — the rules engine fires on `contract_signed` (webhook-driven).
|
||||
const targetStage = documentType === 'contract' ? 'contract' : 'reservation';
|
||||
void advanceStageIfBehind(
|
||||
interestId,
|
||||
portId,
|
||||
'contract_sent',
|
||||
targetStage,
|
||||
meta,
|
||||
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
|
||||
);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({
|
||||
...(documentType === 'contract'
|
||||
? { contractDocStatus: 'sent', dateContractSent: new Date() }
|
||||
: { reservationDocStatus: 'sent' }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, interestId));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
|
||||
212
src/lib/services/deal-health.ts
Normal file
212
src/lib/services/deal-health.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Rule-based deal-health scoring. NO LLMs — every output traces back to a
|
||||
* dated/structured input the rep can see and contest. The chip displayed
|
||||
* on the interest header exposes the per-signal breakdown via tooltip so
|
||||
* an anti-AI stakeholder reading the screen never sees a black box.
|
||||
*
|
||||
* Inputs (all already available on getInterestById):
|
||||
* - pipelineStage + per-doc sub-status (eoiDocStatus, etc.)
|
||||
* - dateLastContact, dateFirstContact, dateEoiSent, dateEoiSigned,
|
||||
* dateReservationSigned, dateContractSent, dateContractSigned,
|
||||
* dateDepositReceived
|
||||
* - depositExpectedAmount (numeric string)
|
||||
*
|
||||
* Scoring rubric (0–100, higher is healthier):
|
||||
* Base 50.
|
||||
* +5 if any activity log entry landed in the last 7 days (active engagement).
|
||||
* +20 if the rep has logged contact in the last 7 days.
|
||||
* +10 if contact within 14 days (and the 7d bonus didn't fire).
|
||||
* -15 if no contact logged in 30+ days.
|
||||
* -10 if the deal is older than 30d and still in 'enquiry' or 'qualified'.
|
||||
* +10 for each stage past enquiry the deal has reached (capped at +30).
|
||||
* -10 if EOI was sent more than 14d ago and isn't signed yet.
|
||||
* -10 if reservation was signed but no deposit recorded in 21d.
|
||||
* -10 if contract was sent more than 14d ago and isn't signed yet.
|
||||
* +5 if outcome is 'won' (sanity bump, though won deals don't show this).
|
||||
*
|
||||
* Score buckets:
|
||||
* ≥70 → 'hot' (green; rep is on top of it)
|
||||
* 40-69 → 'warm' (amber; needs attention)
|
||||
* <40 → 'cold' (rose; at risk)
|
||||
*
|
||||
* The full signals[] array is surfaced to the UI so the tooltip can render
|
||||
* "+20 contacted 3 days ago", "-10 EOI awaiting signature 19d" etc.
|
||||
*/
|
||||
|
||||
import type { PipelineStage } from '@/lib/constants';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
|
||||
export interface DealHealthInput {
|
||||
pipelineStage: string;
|
||||
outcome?: string | null;
|
||||
archivedAt?: string | null;
|
||||
dateFirstContact?: string | Date | null;
|
||||
dateLastContact?: string | Date | null;
|
||||
dateEoiSent?: string | Date | null;
|
||||
dateEoiSigned?: string | Date | null;
|
||||
dateReservationSigned?: string | Date | null;
|
||||
dateContractSent?: string | Date | null;
|
||||
dateContractSigned?: string | Date | null;
|
||||
dateDepositReceived?: string | Date | null;
|
||||
eoiDocStatus?: string | null;
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
/** Optional: count of contact_log entries in the last 7 days. Drives the
|
||||
* +5 "active engagement" signal. When omitted the signal is skipped — keep
|
||||
* the scoring function pure / synchronous so the chip can render without a
|
||||
* separate fetch on every interest list row. */
|
||||
recentActivityCount?: number | null;
|
||||
}
|
||||
|
||||
export interface DealHealthSignal {
|
||||
/** Stable id useful for keying the tooltip rows. */
|
||||
id: string;
|
||||
/** +N or -N (signed integer for explicit math). */
|
||||
delta: number;
|
||||
/** Plain-English explanation surfaced in the tooltip. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DealHealth {
|
||||
score: number;
|
||||
pulse: 'cold' | 'warm' | 'hot';
|
||||
signals: DealHealthSignal[];
|
||||
}
|
||||
|
||||
function daysSince(iso: string | Date | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.floor((Date.now() - t) / 86_400_000);
|
||||
}
|
||||
|
||||
export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
let score = 50;
|
||||
const signals: DealHealthSignal[] = [];
|
||||
|
||||
// Closed / archived deals don't get a pulse score — UI hides the chip
|
||||
// anyway, but compute a neutral score so callers using this in reports
|
||||
// don't crash on undefined.
|
||||
if (input.archivedAt || input.outcome) {
|
||||
return { score: 50, pulse: 'warm', signals: [] };
|
||||
}
|
||||
|
||||
// Active engagement: counts every distinct activity-log entry in the last
|
||||
// 7 days. Surfaces "the rep is actively working this deal" separately from
|
||||
// the coarse dateLastContact bump (which only moves on the most-recent
|
||||
// entry's date). A 5-call week scores +5 once; we don't double-count.
|
||||
if (input.recentActivityCount !== null && input.recentActivityCount !== undefined) {
|
||||
if (input.recentActivityCount >= 1) {
|
||||
score += 5;
|
||||
signals.push({
|
||||
id: 'active_engagement',
|
||||
delta: +5,
|
||||
detail: `${input.recentActivityCount} activity log entr${
|
||||
input.recentActivityCount === 1 ? 'y' : 'ies'
|
||||
} in the last 7d — rep is engaged.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const contactDays = daysSince(input.dateLastContact);
|
||||
if (contactDays !== null) {
|
||||
if (contactDays <= 7) {
|
||||
score += 20;
|
||||
signals.push({
|
||||
id: 'contact_recent',
|
||||
delta: +20,
|
||||
detail: `Contact logged ${contactDays}d ago — fresh.`,
|
||||
});
|
||||
} else if (contactDays <= 14) {
|
||||
score += 10;
|
||||
signals.push({
|
||||
id: 'contact_warm',
|
||||
delta: +10,
|
||||
detail: `Contact logged ${contactDays}d ago — still warm.`,
|
||||
});
|
||||
} else if (contactDays >= 30) {
|
||||
score -= 15;
|
||||
signals.push({
|
||||
id: 'contact_stale',
|
||||
delta: -15,
|
||||
detail: `No contact logged in ${contactDays}d — going cold.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stage progress: every step past enquiry signals momentum.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(input.pipelineStage as PipelineStage);
|
||||
if (stageIdx > 0) {
|
||||
const bonus = Math.min(30, stageIdx * 10);
|
||||
score += bonus;
|
||||
signals.push({
|
||||
id: 'stage_progress',
|
||||
delta: +bonus,
|
||||
detail: `Reached ${input.pipelineStage.replace(/_/g, ' ')} stage.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Age penalty for stuck top-of-funnel leads.
|
||||
const firstDays = daysSince(input.dateFirstContact);
|
||||
if (
|
||||
firstDays !== null &&
|
||||
firstDays >= 30 &&
|
||||
(input.pipelineStage === 'enquiry' || input.pipelineStage === 'qualified')
|
||||
) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'stuck_top_funnel',
|
||||
delta: -10,
|
||||
detail: `Deal opened ${firstDays}d ago and still pre-EOI.`,
|
||||
});
|
||||
}
|
||||
|
||||
// EOI in-flight too long.
|
||||
const eoiSentDays = daysSince(input.dateEoiSent);
|
||||
if (
|
||||
eoiSentDays !== null &&
|
||||
eoiSentDays >= 14 &&
|
||||
input.eoiDocStatus !== 'signed' &&
|
||||
!input.dateEoiSigned
|
||||
) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'eoi_awaiting',
|
||||
delta: -10,
|
||||
detail: `EOI awaiting signature for ${eoiSentDays}d.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Reservation signed but deposit not received.
|
||||
const reservationDays = daysSince(input.dateReservationSigned);
|
||||
if (reservationDays !== null && reservationDays >= 21 && !input.dateDepositReceived) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'deposit_pending',
|
||||
delta: -10,
|
||||
detail: `Reservation signed ${reservationDays}d ago but deposit not recorded.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Contract awaiting signature.
|
||||
const contractSentDays = daysSince(input.dateContractSent);
|
||||
if (
|
||||
contractSentDays !== null &&
|
||||
contractSentDays >= 14 &&
|
||||
input.contractDocStatus !== 'signed' &&
|
||||
!input.dateContractSigned
|
||||
) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'contract_awaiting',
|
||||
delta: -10,
|
||||
detail: `Contract awaiting signature for ${contractSentDays}d.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Clamp to [0, 100].
|
||||
score = Math.max(0, Math.min(100, score));
|
||||
|
||||
const pulse: DealHealth['pulse'] = score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold';
|
||||
return { score, pulse, signals };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - is_in_eoi_bundle : covered by the interest's EOI signature.
|
||||
*/
|
||||
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
|
||||
@@ -289,6 +289,25 @@ export async function upsertInterestBerthTx(
|
||||
set: setForUpdate,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Auto-promote leadCategory: linking a specific berth means the interest
|
||||
// is now anchored to a real piece of inventory, which is the definition
|
||||
// of `specific_qualified`. Only bumps `general_interest` (or null) —
|
||||
// never demotes `hot_lead` or anything else already past qualified.
|
||||
const isSpecific = row?.isSpecificInterest ?? opts.isSpecificInterest ?? true;
|
||||
if (isSpecific) {
|
||||
await tx
|
||||
.update(interests)
|
||||
.set({ leadCategory: 'specific_qualified' })
|
||||
.where(
|
||||
and(eq(interests.id, interestId), inArray(interests.leadCategory, ['general_interest'])),
|
||||
);
|
||||
// Separately handle the NULL case (Drizzle's `inArray` can't include null).
|
||||
await tx.execute(
|
||||
sql`UPDATE interests SET lead_category = 'specific_qualified' WHERE id = ${interestId} AND lead_category IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
return row!;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,16 @@ import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
|
||||
export type ContactDirection = 'outbound' | 'inbound';
|
||||
|
||||
export type ContactTemplate = 'call' | 'visit' | 'email';
|
||||
|
||||
export interface CreateContactLogInput {
|
||||
interestId: string;
|
||||
occurredAt: Date;
|
||||
channel: ContactChannel;
|
||||
direction: ContactDirection;
|
||||
summary: string;
|
||||
voiceTranscript?: string | null;
|
||||
templateUsed?: ContactTemplate | null;
|
||||
followUpAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -43,6 +47,8 @@ export interface UpdateContactLogInput {
|
||||
channel?: ContactChannel;
|
||||
direction?: ContactDirection;
|
||||
summary?: string;
|
||||
voiceTranscript?: string | null;
|
||||
templateUsed?: ContactTemplate | null;
|
||||
followUpAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -114,6 +120,8 @@ export async function create(
|
||||
channel: input.channel,
|
||||
direction: input.direction,
|
||||
summary: input.summary,
|
||||
voiceTranscript: input.voiceTranscript ?? null,
|
||||
templateUsed: input.templateUsed ?? null,
|
||||
followUpAt: input.followUpAt ?? null,
|
||||
reminderId,
|
||||
createdBy: userId,
|
||||
@@ -199,6 +207,8 @@ export async function update(
|
||||
...(input.channel !== undefined && { channel: input.channel }),
|
||||
...(input.direction !== undefined && { direction: input.direction }),
|
||||
...(input.summary !== undefined && { summary: input.summary }),
|
||||
...(input.voiceTranscript !== undefined && { voiceTranscript: input.voiceTranscript }),
|
||||
...(input.templateUsed !== undefined && { templateUsed: input.templateUsed }),
|
||||
followUpAt: newFollowUpAt,
|
||||
reminderId,
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -675,13 +675,16 @@ export async function recordPayment(
|
||||
|
||||
// Deposit invoices linked to a sales interest auto-advance the pipeline.
|
||||
// Only advances forward - no-op if the interest has already moved past
|
||||
// deposit_10pct (e.g. straight-to-contract flows).
|
||||
// deposit_paid (e.g. straight-to-contract flows). NOTE: the v1 sales
|
||||
// refactor introduces a separate `payments` table that supersedes invoice
|
||||
// tracking for the deposit stage; this block stays wired for legacy
|
||||
// invoices but new flows record payments via that pathway instead.
|
||||
if (updated.kind === 'deposit' && updated.interestId) {
|
||||
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehind(
|
||||
updated.interestId,
|
||||
portId,
|
||||
'deposit_10pct',
|
||||
'deposit_paid',
|
||||
meta,
|
||||
`Deposit invoice ${existing.invoiceNumber} paid`,
|
||||
);
|
||||
|
||||
226
src/lib/services/payments.service.ts
Normal file
226
src/lib/services/payments.service.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Payment-records service. The CRM does NOT generate invoices — banks invoice
|
||||
* clients directly. We record that money was received (or refunded) with an
|
||||
* optional uploaded receipt for audit purposes.
|
||||
*
|
||||
* Auto-advance: when the running deposit total (SUM where payment_type='deposit'
|
||||
* minus SUM of refunds) reaches `interests.depositExpectedAmount`, the pipeline
|
||||
* stage moves to 'deposit_paid' (no-op if already past).
|
||||
*/
|
||||
|
||||
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, payments } from '@/lib/db/schema';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreatePaymentInput, UpdatePaymentInput } from '@/lib/validators/payments';
|
||||
|
||||
// ─── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** All payments for a single interest, newest received first. */
|
||||
export async function listPaymentsForInterest(interestId: string, portId: string) {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(and(eq(payments.interestId, interestId), eq(payments.portId, portId)))
|
||||
.orderBy(desc(payments.receivedAt), asc(payments.id));
|
||||
}
|
||||
|
||||
/** Net deposit total for an interest. `deposit` rows add; `refund` rows
|
||||
* subtract (their `amount` may be either positive or already negative — we
|
||||
* always treat refunds as deductions to match the UI convention). */
|
||||
export async function getDepositTotalForInterest(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<{ total: string; currency: string }> {
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentType: payments.paymentType,
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
})
|
||||
.from(payments)
|
||||
.where(
|
||||
and(
|
||||
eq(payments.interestId, interestId),
|
||||
eq(payments.portId, portId),
|
||||
sql`${payments.paymentType} IN ('deposit', 'refund')`,
|
||||
),
|
||||
);
|
||||
|
||||
// Use BigInt-ish accumulator via Number — amounts are EUR scale; we don't
|
||||
// need cent-precise math for the auto-advance gate, but we DO normalize the
|
||||
// sign of refunds so a refund stored as "+200" still subtracts.
|
||||
let net = 0;
|
||||
let currency = 'EUR';
|
||||
for (const row of rows) {
|
||||
const n = Number(row.amount);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
currency = row.currency;
|
||||
net += row.paymentType === 'refund' ? -Math.abs(n) : n;
|
||||
}
|
||||
return { total: net.toFixed(2), currency };
|
||||
}
|
||||
|
||||
// ─── Writes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createPayment(portId: string, data: CreatePaymentInput, meta: AuditMeta) {
|
||||
// Resolve interest + sanity-check it belongs to this port.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, data.interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true, clientId: true, depositExpectedAmount: true, pipelineStage: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
const amountNum = Number(data.amount);
|
||||
if (!Number.isFinite(amountNum) || amountNum === 0) {
|
||||
throw new ValidationError('amount must be a non-zero numeric value');
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(payments)
|
||||
.values({
|
||||
portId,
|
||||
interestId: data.interestId,
|
||||
clientId: interest.clientId,
|
||||
paymentType: data.paymentType,
|
||||
amount: data.amount,
|
||||
currency: data.currency,
|
||||
receivedAt: new Date(data.receivedAt),
|
||||
receiptFileId: data.receiptFileId ?? null,
|
||||
notes: data.notes ?? null,
|
||||
recordedBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'payment',
|
||||
entityId: row!.id,
|
||||
newValue: {
|
||||
interestId: data.interestId,
|
||||
paymentType: data.paymentType,
|
||||
amount: data.amount,
|
||||
currency: data.currency,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'payment:created', {
|
||||
paymentId: row!.id,
|
||||
interestId: data.interestId,
|
||||
paymentType: data.paymentType,
|
||||
});
|
||||
|
||||
// Auto-advance: when the running deposit total reaches the expected amount,
|
||||
// promote the stage to 'deposit_paid'. Dynamic import keeps the
|
||||
// payments ↔ interests cycle one-way at module-load time.
|
||||
if (data.paymentType === 'deposit' || data.paymentType === 'refund') {
|
||||
const { total } = await getDepositTotalForInterest(data.interestId, portId);
|
||||
const expected = interest.depositExpectedAmount ? Number(interest.depositExpectedAmount) : null;
|
||||
if (expected !== null && Number.isFinite(expected) && Number(total) >= expected) {
|
||||
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehind(
|
||||
data.interestId,
|
||||
portId,
|
||||
'deposit_paid',
|
||||
meta,
|
||||
`Deposit total (${total} ${data.currency}) reached expected amount`,
|
||||
);
|
||||
|
||||
// Stamp dateDepositReceived if not already set so the timeline shows
|
||||
// when the threshold was met (not the date of the first payment row).
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ dateDepositReceived: new Date(), updatedAt: new Date() })
|
||||
.where(eq(interests.id, data.interestId));
|
||||
|
||||
// Berth rule fires via the same hook the legacy invoices.ts path uses.
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
void evaluateRule('deposit_received', data.interestId, portId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updatePayment(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdatePaymentInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.payments.findFirst({
|
||||
where: and(eq(payments.id, id), eq(payments.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Payment');
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if (data.paymentType !== undefined) next.paymentType = data.paymentType;
|
||||
if (data.amount !== undefined) next.amount = data.amount;
|
||||
if (data.currency !== undefined) next.currency = data.currency;
|
||||
if (data.receivedAt !== undefined) next.receivedAt = new Date(data.receivedAt);
|
||||
if (data.receiptFileId !== undefined) next.receiptFileId = data.receiptFileId;
|
||||
if (data.notes !== undefined) next.notes = data.notes;
|
||||
|
||||
const [updated] = await db
|
||||
.update(payments)
|
||||
.set(next)
|
||||
.where(and(eq(payments.id, id), eq(payments.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'payment',
|
||||
entityId: id,
|
||||
oldValue: {
|
||||
paymentType: existing.paymentType,
|
||||
amount: existing.amount,
|
||||
currency: existing.currency,
|
||||
},
|
||||
newValue: next,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function deletePayment(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.payments.findFirst({
|
||||
where: and(eq(payments.id, id), eq(payments.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Payment');
|
||||
|
||||
await db.delete(payments).where(and(eq(payments.id, id), eq(payments.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'payment',
|
||||
entityId: id,
|
||||
oldValue: {
|
||||
paymentType: existing.paymentType,
|
||||
amount: existing.amount,
|
||||
currency: existing.currency,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
278
src/lib/services/qualification.service.ts
Normal file
278
src/lib/services/qualification.service.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Qualification-criteria service. Per-port admins configure the criteria that
|
||||
* a deal must satisfy to be considered "qualified" (the gate between enquiry
|
||||
* and the rest of the pipeline). Per-interest state is captured separately
|
||||
* so changing the port's criteria doesn't retroactively affect existing
|
||||
* deals.
|
||||
*
|
||||
* The "fully qualified" derivation drives the soft hint on the interest
|
||||
* detail page that an enquiry is ready to advance.
|
||||
*/
|
||||
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type {
|
||||
CreateQualificationCriterionInput,
|
||||
SetInterestQualificationInput,
|
||||
UpdateQualificationCriterionInput,
|
||||
} from '@/lib/validators/qualification';
|
||||
|
||||
// ─── Port-scoped criterion config (admin) ───────────────────────────────────
|
||||
|
||||
export async function listCriteriaForPort(portId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(qualificationCriteria)
|
||||
.where(eq(qualificationCriteria.portId, portId))
|
||||
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
|
||||
}
|
||||
|
||||
export async function createCriterion(
|
||||
portId: string,
|
||||
data: CreateQualificationCriterionInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
// Unique (portId, key) is enforced at DB level, but doing the check here
|
||||
// surfaces a friendlier 409 with the offending key.
|
||||
const existing = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.key, data.key)),
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictError(`A criterion with key "${data.key}" already exists for this port`);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(qualificationCriteria)
|
||||
.values({
|
||||
portId,
|
||||
key: data.key,
|
||||
label: data.label,
|
||||
description: data.description ?? null,
|
||||
enabled: data.enabled,
|
||||
displayOrder: data.displayOrder,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'qualification_criterion',
|
||||
entityId: row!.id,
|
||||
newValue: { key: data.key, label: data.label, enabled: data.enabled },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updateCriterion(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdateQualificationCriterionInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Qualification criterion');
|
||||
|
||||
const next: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.label !== undefined) next.label = data.label;
|
||||
if (data.description !== undefined) next.description = data.description;
|
||||
if (data.enabled !== undefined) next.enabled = data.enabled;
|
||||
if (data.displayOrder !== undefined) next.displayOrder = data.displayOrder;
|
||||
|
||||
const [updated] = await db
|
||||
.update(qualificationCriteria)
|
||||
.set(next)
|
||||
.where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'qualification_criterion',
|
||||
entityId: id,
|
||||
oldValue: { label: existing.label, enabled: existing.enabled },
|
||||
newValue: next,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function deleteCriterion(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Qualification criterion');
|
||||
|
||||
// Per-interest state rows reference the key, not the criterion id, so they
|
||||
// survive a criterion deletion as historical noise. UI hides rows whose key
|
||||
// no longer matches an active criterion.
|
||||
await db
|
||||
.delete(qualificationCriteria)
|
||||
.where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'qualification_criterion',
|
||||
entityId: id,
|
||||
oldValue: { key: existing.key, label: existing.label },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─── Per-interest state ─────────────────────────────────────────────────────
|
||||
|
||||
export interface QualificationRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
confirmed: boolean;
|
||||
confirmedAt: Date | null;
|
||||
confirmedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The qualification state for a specific interest, joined with the port's
|
||||
* current criterion definitions. Returns only currently-enabled criteria —
|
||||
* disabled ones are hidden from the rep but their state rows are preserved
|
||||
* in the DB for audit.
|
||||
*/
|
||||
export async function listInterestQualifications(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<QualificationRow[]> {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
const criteria = await db
|
||||
.select()
|
||||
.from(qualificationCriteria)
|
||||
.where(and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.enabled, true)))
|
||||
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
|
||||
|
||||
const states = await db
|
||||
.select()
|
||||
.from(interestQualifications)
|
||||
.where(eq(interestQualifications.interestId, interestId));
|
||||
const stateByKey = new Map(states.map((s) => [s.criterionKey, s] as const));
|
||||
|
||||
return criteria.map((c) => {
|
||||
const s = stateByKey.get(c.key);
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
displayOrder: c.displayOrder,
|
||||
confirmed: s?.confirmed ?? false,
|
||||
confirmedAt: s?.confirmedAt ?? null,
|
||||
confirmedBy: s?.confirmedBy ?? null,
|
||||
notes: s?.notes ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single criterion's confirmed-state for an interest. Stamping the
|
||||
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
|
||||
* audit record — the caller can't backdate it.
|
||||
*/
|
||||
export async function setInterestQualification(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
data: SetInterestQualificationInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
// Refuse keys the port doesn't have a criterion for — keeps state rows
|
||||
// referentially consistent with the visible config.
|
||||
const criterion = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(
|
||||
eq(qualificationCriteria.portId, portId),
|
||||
eq(qualificationCriteria.key, data.criterionKey),
|
||||
),
|
||||
});
|
||||
if (!criterion) throw new NotFoundError('Qualification criterion');
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(interestQualifications)
|
||||
.values({
|
||||
interestId,
|
||||
criterionKey: data.criterionKey,
|
||||
confirmed: data.confirmed,
|
||||
confirmedAt: data.confirmed ? now : null,
|
||||
confirmedBy: data.confirmed ? meta.userId : null,
|
||||
notes: data.notes ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [interestQualifications.interestId, interestQualifications.criterionKey],
|
||||
set: {
|
||||
confirmed: data.confirmed,
|
||||
confirmedAt: data.confirmed ? now : null,
|
||||
confirmedBy: data.confirmed ? meta.userId : null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'interest_qualification',
|
||||
entityId: `${interestId}:${data.criterionKey}`,
|
||||
newValue: { confirmed: data.confirmed, key: data.criterionKey },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'interest:qualificationChanged', {
|
||||
interestId,
|
||||
criterionKey: data.criterionKey,
|
||||
confirmed: data.confirmed,
|
||||
});
|
||||
|
||||
return listInterestQualifications(interestId, portId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when every enabled criterion for the port is confirmed for
|
||||
* the given interest. Used by the UI to surface the "ready to qualify" hint
|
||||
* and by the auto-advance helper to soft-suggest moving to 'qualified'.
|
||||
*/
|
||||
export async function isInterestFullyQualified(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await listInterestQualifications(interestId, portId);
|
||||
if (rows.length === 0) return false;
|
||||
return rows.every((r) => r.confirmed);
|
||||
}
|
||||
@@ -113,13 +113,13 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['labels', 'categories', 'classification'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/profile',
|
||||
href: '/:portSlug/settings',
|
||||
label: 'Notification preferences',
|
||||
category: 'settings',
|
||||
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/profile',
|
||||
href: '/:portSlug/settings',
|
||||
label: 'My profile & preferences',
|
||||
category: 'settings',
|
||||
keywords: [
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface ClientResult {
|
||||
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
|
||||
archivedAt: string | null;
|
||||
relatedVia?: RelatedVia | null;
|
||||
/** Short label for which field matched ("name", "email", "phone", "trigram", "expansion"). Used by the dropdown to render "matched on X". */
|
||||
matchedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface ResidentialClientResult {
|
||||
@@ -451,13 +453,25 @@ async function searchClients(
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return Array.from(rows).map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
matchedContact: r.matched_value ?? null,
|
||||
matchedContactChannel: r.matched_channel ?? null,
|
||||
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
|
||||
}));
|
||||
return Array.from(rows).map((r) => {
|
||||
// Tag the rank tier we picked back as a human-readable label so the
|
||||
// dropdown can render "matched on name" / "matched on email" without
|
||||
// the UI re-doing the comparison. Mirrors the CASE in `rank` above.
|
||||
let matchedOn: string | null = null;
|
||||
if (r.rank >= 80) matchedOn = 'name';
|
||||
else if (r.rank >= 70) matchedOn = 'name';
|
||||
else if (r.rank >= 60) matchedOn = r.matched_channel ?? 'contact';
|
||||
else if (r.rank >= 55) matchedOn = 'phone';
|
||||
else if (r.rank >= 30) matchedOn = 'similar name';
|
||||
return {
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
matchedContact: r.matched_value ?? null,
|
||||
matchedContactChannel: r.matched_channel ?? null,
|
||||
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
|
||||
matchedOn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function searchResidentialClients(
|
||||
@@ -1870,17 +1884,38 @@ export async function search(
|
||||
// who search "A10" see the linked interests/clients/yachts/companies
|
||||
// surface alongside the berth. See `expandGraph` docstring for the
|
||||
// relationship map and per-bucket caps.
|
||||
const expanded = await expandGraph(
|
||||
portId,
|
||||
{
|
||||
berthIds: berths.map((b) => b.id),
|
||||
interestIds: interests.map((i) => i.id),
|
||||
clientIds: clients.map((c) => c.id),
|
||||
yachtIds: yachts.map((y) => y.id),
|
||||
companyIds: companies.map((c) => c.id),
|
||||
},
|
||||
limit,
|
||||
);
|
||||
//
|
||||
// Latency optimization: when every relationship-bearing bucket already
|
||||
// has the maximum number of direct matches the dropdown will render,
|
||||
// graph expansion only adds rows that get truncated downstream — skip
|
||||
// the (cross-table-heavy) expansion query entirely. Saves the biggest
|
||||
// single SQL call in the search path on common-term queries.
|
||||
const allBucketsFull =
|
||||
clients.length >= limit &&
|
||||
yachts.length >= limit &&
|
||||
companies.length >= limit &&
|
||||
interests.length >= limit &&
|
||||
berths.length >= limit;
|
||||
|
||||
const expanded = allBucketsFull
|
||||
? {
|
||||
interests: [] as InterestResult[],
|
||||
clients: [] as ClientResult[],
|
||||
yachts: [] as YachtResult[],
|
||||
companies: [] as CompanyResult[],
|
||||
berths: [] as BerthResult[],
|
||||
}
|
||||
: await expandGraph(
|
||||
portId,
|
||||
{
|
||||
berthIds: berths.map((b) => b.id),
|
||||
interestIds: interests.map((i) => i.id),
|
||||
clientIds: clients.map((c) => c.id),
|
||||
yachtIds: yachts.map((y) => y.id),
|
||||
companyIds: companies.map((c) => c.id),
|
||||
},
|
||||
limit,
|
||||
);
|
||||
|
||||
const apply = <T extends { id: string }>(rows: T[]) =>
|
||||
applyAffinity(rows, opts.recentlyTouchedIds);
|
||||
|
||||
@@ -86,6 +86,11 @@ export interface ServerToClientEvents {
|
||||
'residential_interest:created': (payload: { id: string }) => void;
|
||||
'residential_interest:updated': (payload: { id: string }) => void;
|
||||
'residential_interest:archived': (payload: { id: string }) => void;
|
||||
'interest:qualificationChanged': (payload: {
|
||||
interestId: string;
|
||||
criterionKey: string;
|
||||
confirmed: boolean;
|
||||
}) => void;
|
||||
'interest:leadCategoryChanged': (payload: {
|
||||
interestId: string;
|
||||
oldCategory: string;
|
||||
@@ -200,6 +205,13 @@ export interface ServerToClientEvents {
|
||||
invoiceNumber: string;
|
||||
daysPastDue: number;
|
||||
}) => void;
|
||||
'payment:created': (payload: {
|
||||
paymentId: string;
|
||||
interestId: string;
|
||||
paymentType: string;
|
||||
}) => void;
|
||||
'payment:updated': (payload: { paymentId: string; interestId: string }) => void;
|
||||
'payment:deleted': (payload: { paymentId: string; interestId: string }) => void;
|
||||
|
||||
// Reminder & Calendar events
|
||||
'reminder:created': (payload: {
|
||||
|
||||
@@ -18,6 +18,14 @@ export const SUPPORTED_CURRENCIES = [
|
||||
{ code: 'AED', symbol: 'د.إ', label: 'UAE Dirham' },
|
||||
{ code: 'SGD', symbol: 'S$', label: 'Singapore Dollar' },
|
||||
{ code: 'HKD', symbol: 'HK$', label: 'Hong Kong Dollar' },
|
||||
{ code: 'PAB', symbol: 'B/.', label: 'Panamanian Balboa' },
|
||||
{ code: 'XCD', symbol: 'EC$', label: 'East Caribbean Dollar' },
|
||||
{ code: 'BSD', symbol: 'B$', label: 'Bahamian Dollar' },
|
||||
{ code: 'KYD', symbol: 'CI$', label: 'Cayman Islands Dollar' },
|
||||
{ code: 'BBD', symbol: 'Bds$', label: 'Barbadian Dollar' },
|
||||
{ code: 'DOP', symbol: 'RD$', label: 'Dominican Peso' },
|
||||
{ code: 'JMD', symbol: 'J$', label: 'Jamaican Dollar' },
|
||||
{ code: 'TTD', symbol: 'TT$', label: 'Trinidad & Tobago Dollar' },
|
||||
] as const;
|
||||
|
||||
export type SupportedCurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]['code'];
|
||||
|
||||
@@ -5,12 +5,20 @@ const DIRECTIONS = ['outbound', 'inbound'] as const;
|
||||
|
||||
/** Cap summary length so a rep can't accidentally paste a 10MB email body. */
|
||||
const SUMMARY_MAX = 4000;
|
||||
/** Voice transcripts can run long for meetings; cap a bit higher than summary. */
|
||||
const TRANSCRIPT_MAX = 20_000;
|
||||
const TEMPLATES = ['call', 'visit', 'email'] as const;
|
||||
|
||||
export const createContactLogSchema = z.object({
|
||||
occurredAt: z.coerce.date(),
|
||||
channel: z.enum(CHANNELS),
|
||||
direction: z.enum(DIRECTIONS).default('outbound'),
|
||||
summary: z.string().min(1).max(SUMMARY_MAX),
|
||||
/** Raw Web Speech transcript, optional. Kept verbatim alongside the
|
||||
* rep-polished summary so an edit to summary doesn't lose the original. */
|
||||
voiceTranscript: z.string().max(TRANSCRIPT_MAX).optional().nullable(),
|
||||
/** Which quick-template button (if any) seeded this entry. */
|
||||
templateUsed: z.enum(TEMPLATES).optional().nullable(),
|
||||
followUpAt: z.coerce.date().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -20,6 +28,8 @@ export const updateContactLogSchema = z
|
||||
channel: z.enum(CHANNELS),
|
||||
direction: z.enum(DIRECTIONS),
|
||||
summary: z.string().min(1).max(SUMMARY_MAX),
|
||||
voiceTranscript: z.string().max(TRANSCRIPT_MAX).nullable(),
|
||||
templateUsed: z.enum(TEMPLATES).nullable(),
|
||||
followUpAt: z.coerce.date().nullable(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -36,7 +36,36 @@ export const createInterestSchema = z.object({
|
||||
clientId: z.string().min(1),
|
||||
yachtId: z.string().optional(),
|
||||
berthId: z.string().optional(),
|
||||
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
|
||||
/** Sales rep who owns this deal. Empty string treated as "unassign";
|
||||
* omitting the field leaves the current assignment unchanged. On create,
|
||||
* omitting falls back to system_settings.default_new_interest_owner. */
|
||||
assignedTo: z.string().nullable().optional(),
|
||||
/** Captured at reservation-agreement time. Drives the deposit-paid
|
||||
* auto-advance once payment totals catch up. */
|
||||
depositExpectedAmount: z.string().optional().nullable(),
|
||||
depositExpectedCurrency: z.string().length(3).optional(),
|
||||
/** Doc sub-status badges. Stamped automatically by the Documenso webhook
|
||||
* + custom-upload pathway; exposed via the update endpoint so reps can
|
||||
* "Mark signed manually" from the milestone strip when a doc was signed
|
||||
* outside the Documenso flow (e.g. an in-person paper signing). */
|
||||
eoiDocStatus: z.enum(['pending', 'sent', 'signed', 'declined', 'voided']).nullable().optional(),
|
||||
reservationDocStatus: z
|
||||
.enum(['pending', 'sent', 'signed', 'declined', 'voided'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
contractDocStatus: z
|
||||
.enum(['pending', 'sent', 'signed', 'declined', 'voided'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
/** Milestone dates exposed for manual stamping via PATCH; auto-stamped
|
||||
* by the signing flows when reps use the Documenso pathway. Coerced
|
||||
* to a Date so Drizzle gets the right type for the timestamptz column. */
|
||||
dateEoiSent: z.coerce.date().nullable().optional(),
|
||||
dateEoiSigned: z.coerce.date().nullable().optional(),
|
||||
dateReservationSigned: z.coerce.date().nullable().optional(),
|
||||
dateContractSent: z.coerce.date().nullable().optional(),
|
||||
dateContractSigned: z.coerce.date().nullable().optional(),
|
||||
pipelineStage: z.enum(PIPELINE_STAGES).default('enquiry'),
|
||||
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
||||
source: z.string().optional(),
|
||||
tagIds: z.array(z.string()).optional().default([]),
|
||||
|
||||
27
src/lib/validators/payments.ts
Normal file
27
src/lib/validators/payments.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PAYMENT_TYPES = ['deposit', 'balance', 'refund', 'other'] as const;
|
||||
export type PaymentType = (typeof PAYMENT_TYPES)[number];
|
||||
|
||||
const decimalString = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^-?\d+(\.\d+)?$/, 'Must be a numeric string (e.g. "1500" or "1500.00")');
|
||||
|
||||
export const createPaymentSchema = z.object({
|
||||
interestId: z.string().min(1),
|
||||
paymentType: z.enum(PAYMENT_TYPES),
|
||||
amount: decimalString,
|
||||
currency: z.string().length(3).default('EUR'),
|
||||
receivedAt: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((v) => !Number.isNaN(Date.parse(v)), 'Must be a parseable date/datetime string'),
|
||||
receiptFileId: z.string().optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const updatePaymentSchema = createPaymentSchema.omit({ interestId: true }).partial();
|
||||
|
||||
export type CreatePaymentInput = z.infer<typeof createPaymentSchema>;
|
||||
export type UpdatePaymentInput = z.infer<typeof updatePaymentSchema>;
|
||||
38
src/lib/validators/qualification.ts
Normal file
38
src/lib/validators/qualification.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Per-port qualification criterion. Admin-configurable: label / description /
|
||||
* enabled state / display order. The `key` is the stable identifier code
|
||||
* references (templates, derivations) — it can't be changed after creation
|
||||
* because per-interest state rows reference it via composite PK.
|
||||
*/
|
||||
export const createQualificationCriterionSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9_]*$/, 'Must be lowercase alphanumeric with underscores'),
|
||||
label: z.string().min(1).max(120),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
enabled: z.boolean().default(true),
|
||||
displayOrder: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const updateQualificationCriterionSchema = createQualificationCriterionSchema
|
||||
.omit({ key: true })
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* Per-interest qualification state. Only `confirmed` + optional `notes` are
|
||||
* writable — `confirmedAt` / `confirmedBy` are stamped server-side from
|
||||
* the auth context.
|
||||
*/
|
||||
export const setInterestQualificationSchema = z.object({
|
||||
criterionKey: z.string().min(1),
|
||||
confirmed: z.boolean(),
|
||||
notes: z.string().max(500).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateQualificationCriterionInput = z.infer<typeof createQualificationCriterionSchema>;
|
||||
export type UpdateQualificationCriterionInput = z.infer<typeof updateQualificationCriterionSchema>;
|
||||
export type SetInterestQualificationInput = z.infer<typeof setInterestQualificationSchema>;
|
||||
Reference in New Issue
Block a user