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

Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 deletions

View File

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

View File

@@ -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 (01).
// 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 {

View 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);

View 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;

View File

@@ -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';

View File

@@ -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),
],
);

View File

@@ -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`. */

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 (0100, 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 };
}

View File

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

View File

@@ -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!;
}

View File

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

View File

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

View File

@@ -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`,
);

View 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 };
}

View 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);
}

View File

@@ -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: [

View File

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

View File

@@ -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: {

View File

@@ -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'];

View File

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

View File

@@ -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([]),

View 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>;

View 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>;