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:
@@ -133,7 +133,8 @@ export async function computePipelineFunnel(
|
||||
.groupBy(interests.pipelineStage);
|
||||
|
||||
const counts = new Map(stageRows.map((r) => [r.stage, r.count]));
|
||||
const top = counts.get('open') ?? 0;
|
||||
// First stage in the canonical order anchors the conversion percentage.
|
||||
const top = counts.get(PIPELINE_STAGES[0]) ?? 0;
|
||||
|
||||
const stages = PIPELINE_STAGES.map((stage) => {
|
||||
const count = counts.get(stage) ?? 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
@@ -7,6 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -94,45 +95,105 @@ export async function evaluateRule(
|
||||
const rulesConfig = await getRulesConfig(portId);
|
||||
const rule = rulesConfig[trigger];
|
||||
|
||||
// Decision-trace audit: ALWAYS record what we decided to do (or not do),
|
||||
// including the rule mode, so admins can debug "why didn't this fire?" /
|
||||
// "why did this fire" without grepping server logs. Tagged `berth_rule_decision`
|
||||
// so it's distinct from the actual mutation audit row below.
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'rule_evaluated',
|
||||
entityType: 'berth',
|
||||
entityId: targetBerthId,
|
||||
metadata: {
|
||||
type: 'berth_rule_decision',
|
||||
trigger,
|
||||
mode: rule.mode,
|
||||
targetStatus: rule.targetStatus,
|
||||
interestId,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
if (rule.mode === 'off') {
|
||||
return { action: 'none' };
|
||||
}
|
||||
|
||||
if (rule.mode === 'auto') {
|
||||
await db
|
||||
.update(berths)
|
||||
.set({
|
||||
status: rule.targetStatus,
|
||||
statusLastChangedBy: meta.userId,
|
||||
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
||||
statusLastModified: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||
// Concurrency hardening: wrap the read-then-write in a transaction with a
|
||||
// berth-scoped advisory lock so two concurrent webhook retries can't both
|
||||
// commit the same status flip (which produces duplicate audit rows + a
|
||||
// double socket emit). Also short-circuit when the target status is
|
||||
// already in place — re-writing 'sold'→'sold' is technically harmless
|
||||
// but pollutes the audit trail and the socket stream.
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// pg_advisory_xact_lock takes a single bigint. We hash port+berth into
|
||||
// a stable 32-bit slot. The lock auto-releases at transaction end so
|
||||
// there's no risk of a stuck lock if the handler crashes mid-write.
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtext(${`berth-rule:${portId}:${targetBerthId}`}))`,
|
||||
);
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'berth',
|
||||
entityId: targetBerthId,
|
||||
newValue: { status: rule.targetStatus },
|
||||
metadata: { type: 'berth_rule_auto', trigger, interestId },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
// Re-read inside the lock so we observe the post-lock state, not the
|
||||
// pre-lock snapshot. If the prior contender already moved status to
|
||||
// our target, we're idempotent and bail.
|
||||
const [current] = await tx
|
||||
.select({ status: berths.status })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||
|
||||
if (!current) return { changed: false as const };
|
||||
if (current.status === rule.targetStatus) {
|
||||
// Idempotent re-fire. We already audited the decision above; nothing
|
||||
// more to do here.
|
||||
logger.debug(
|
||||
{ trigger, targetBerthId, portId, status: current.status },
|
||||
'Berth-rule auto: target status already set, skipping duplicate write',
|
||||
);
|
||||
return { changed: false as const };
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(berths)
|
||||
.set({
|
||||
status: rule.targetStatus,
|
||||
statusLastChangedBy: meta.userId,
|
||||
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
||||
statusLastModified: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
|
||||
|
||||
return { changed: true as const, previousStatus: current.status };
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
|
||||
berthId: targetBerthId,
|
||||
newStatus: rule.targetStatus,
|
||||
triggeredBy: meta.userId,
|
||||
trigger,
|
||||
});
|
||||
if (result.changed) {
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'berth',
|
||||
entityId: targetBerthId,
|
||||
oldValue: { status: result.previousStatus },
|
||||
newValue: { status: rule.targetStatus },
|
||||
metadata: { type: 'berth_rule_auto', trigger, interestId },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
|
||||
berthId: targetBerthId,
|
||||
newStatus: rule.targetStatus,
|
||||
triggeredBy: meta.userId,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
return { action: 'applied', newStatus: rule.targetStatus };
|
||||
}
|
||||
|
||||
// suggest mode
|
||||
// suggest mode — the decision-trace audit above already records the suggestion.
|
||||
return {
|
||||
action: 'suggested',
|
||||
newStatus: rule.targetStatus,
|
||||
|
||||
@@ -34,10 +34,8 @@ import type { PipelineStage } from '@/lib/constants';
|
||||
* a reason for these clients.
|
||||
*/
|
||||
export const HIGH_STAKES_STAGES: ReadonlySet<PipelineStage> = new Set<PipelineStage>([
|
||||
'deposit_10pct',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
'deposit_paid',
|
||||
'contract',
|
||||
]);
|
||||
|
||||
export type ArchiveStakeLevel = 'low' | 'high';
|
||||
@@ -433,18 +431,18 @@ export async function getClientArchiveDossier(
|
||||
}
|
||||
|
||||
// Stage rank used to pick the "highest" high-stakes stage when surfacing
|
||||
// the warning copy. Higher = more committed.
|
||||
// the warning copy. Higher = more committed. Doc sub-status is folded back
|
||||
// in via the caller (treating eoi+signed as past nurturing, contract+signed
|
||||
// as the apex).
|
||||
function rankStage(s: PipelineStage): number {
|
||||
switch (s) {
|
||||
case 'completed':
|
||||
return 5;
|
||||
case 'contract_signed':
|
||||
case 'contract':
|
||||
return 4;
|
||||
case 'contract_sent':
|
||||
case 'deposit_paid':
|
||||
return 3;
|
||||
case 'deposit_10pct':
|
||||
case 'reservation':
|
||||
return 2;
|
||||
case 'eoi_signed':
|
||||
case 'eoi':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
|
||||
@@ -345,17 +345,28 @@ export async function uploadDocumentForSigning(
|
||||
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
|
||||
// Pipeline transition: contract_sent stage when contract or
|
||||
// reservation_agreement goes out for signing. eoi_sent is reserved
|
||||
// for the template-driven EOI flow. No berth-rules trigger here —
|
||||
// the rules engine fires on `contract_signed` (webhook-driven).
|
||||
// Pipeline transition: contract / reservation custom-upload goes out
|
||||
// for signing. Stamps the matching doc-status sub-state so the badge
|
||||
// flips to 'sent' immediately. EOI stage is reserved for the template
|
||||
// pathway and stamped from documents.service.ts. No berth-rules trigger
|
||||
// here — the rules engine fires on `contract_signed` (webhook-driven).
|
||||
const targetStage = documentType === 'contract' ? 'contract' : 'reservation';
|
||||
void advanceStageIfBehind(
|
||||
interestId,
|
||||
portId,
|
||||
'contract_sent',
|
||||
targetStage,
|
||||
meta,
|
||||
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
|
||||
);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({
|
||||
...(documentType === 'contract'
|
||||
? { contractDocStatus: 'sent', dateContractSent: new Date() }
|
||||
: { reservationDocStatus: 'sent' }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, interestId));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
|
||||
212
src/lib/services/deal-health.ts
Normal file
212
src/lib/services/deal-health.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Rule-based deal-health scoring. NO LLMs — every output traces back to a
|
||||
* dated/structured input the rep can see and contest. The chip displayed
|
||||
* on the interest header exposes the per-signal breakdown via tooltip so
|
||||
* an anti-AI stakeholder reading the screen never sees a black box.
|
||||
*
|
||||
* Inputs (all already available on getInterestById):
|
||||
* - pipelineStage + per-doc sub-status (eoiDocStatus, etc.)
|
||||
* - dateLastContact, dateFirstContact, dateEoiSent, dateEoiSigned,
|
||||
* dateReservationSigned, dateContractSent, dateContractSigned,
|
||||
* dateDepositReceived
|
||||
* - depositExpectedAmount (numeric string)
|
||||
*
|
||||
* Scoring rubric (0–100, higher is healthier):
|
||||
* Base 50.
|
||||
* +5 if any activity log entry landed in the last 7 days (active engagement).
|
||||
* +20 if the rep has logged contact in the last 7 days.
|
||||
* +10 if contact within 14 days (and the 7d bonus didn't fire).
|
||||
* -15 if no contact logged in 30+ days.
|
||||
* -10 if the deal is older than 30d and still in 'enquiry' or 'qualified'.
|
||||
* +10 for each stage past enquiry the deal has reached (capped at +30).
|
||||
* -10 if EOI was sent more than 14d ago and isn't signed yet.
|
||||
* -10 if reservation was signed but no deposit recorded in 21d.
|
||||
* -10 if contract was sent more than 14d ago and isn't signed yet.
|
||||
* +5 if outcome is 'won' (sanity bump, though won deals don't show this).
|
||||
*
|
||||
* Score buckets:
|
||||
* ≥70 → 'hot' (green; rep is on top of it)
|
||||
* 40-69 → 'warm' (amber; needs attention)
|
||||
* <40 → 'cold' (rose; at risk)
|
||||
*
|
||||
* The full signals[] array is surfaced to the UI so the tooltip can render
|
||||
* "+20 contacted 3 days ago", "-10 EOI awaiting signature 19d" etc.
|
||||
*/
|
||||
|
||||
import type { PipelineStage } from '@/lib/constants';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
|
||||
export interface DealHealthInput {
|
||||
pipelineStage: string;
|
||||
outcome?: string | null;
|
||||
archivedAt?: string | null;
|
||||
dateFirstContact?: string | Date | null;
|
||||
dateLastContact?: string | Date | null;
|
||||
dateEoiSent?: string | Date | null;
|
||||
dateEoiSigned?: string | Date | null;
|
||||
dateReservationSigned?: string | Date | null;
|
||||
dateContractSent?: string | Date | null;
|
||||
dateContractSigned?: string | Date | null;
|
||||
dateDepositReceived?: string | Date | null;
|
||||
eoiDocStatus?: string | null;
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
/** Optional: count of contact_log entries in the last 7 days. Drives the
|
||||
* +5 "active engagement" signal. When omitted the signal is skipped — keep
|
||||
* the scoring function pure / synchronous so the chip can render without a
|
||||
* separate fetch on every interest list row. */
|
||||
recentActivityCount?: number | null;
|
||||
}
|
||||
|
||||
export interface DealHealthSignal {
|
||||
/** Stable id useful for keying the tooltip rows. */
|
||||
id: string;
|
||||
/** +N or -N (signed integer for explicit math). */
|
||||
delta: number;
|
||||
/** Plain-English explanation surfaced in the tooltip. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface DealHealth {
|
||||
score: number;
|
||||
pulse: 'cold' | 'warm' | 'hot';
|
||||
signals: DealHealthSignal[];
|
||||
}
|
||||
|
||||
function daysSince(iso: string | Date | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = iso instanceof Date ? iso.getTime() : new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.floor((Date.now() - t) / 86_400_000);
|
||||
}
|
||||
|
||||
export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
let score = 50;
|
||||
const signals: DealHealthSignal[] = [];
|
||||
|
||||
// Closed / archived deals don't get a pulse score — UI hides the chip
|
||||
// anyway, but compute a neutral score so callers using this in reports
|
||||
// don't crash on undefined.
|
||||
if (input.archivedAt || input.outcome) {
|
||||
return { score: 50, pulse: 'warm', signals: [] };
|
||||
}
|
||||
|
||||
// Active engagement: counts every distinct activity-log entry in the last
|
||||
// 7 days. Surfaces "the rep is actively working this deal" separately from
|
||||
// the coarse dateLastContact bump (which only moves on the most-recent
|
||||
// entry's date). A 5-call week scores +5 once; we don't double-count.
|
||||
if (input.recentActivityCount !== null && input.recentActivityCount !== undefined) {
|
||||
if (input.recentActivityCount >= 1) {
|
||||
score += 5;
|
||||
signals.push({
|
||||
id: 'active_engagement',
|
||||
delta: +5,
|
||||
detail: `${input.recentActivityCount} activity log entr${
|
||||
input.recentActivityCount === 1 ? 'y' : 'ies'
|
||||
} in the last 7d — rep is engaged.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const contactDays = daysSince(input.dateLastContact);
|
||||
if (contactDays !== null) {
|
||||
if (contactDays <= 7) {
|
||||
score += 20;
|
||||
signals.push({
|
||||
id: 'contact_recent',
|
||||
delta: +20,
|
||||
detail: `Contact logged ${contactDays}d ago — fresh.`,
|
||||
});
|
||||
} else if (contactDays <= 14) {
|
||||
score += 10;
|
||||
signals.push({
|
||||
id: 'contact_warm',
|
||||
delta: +10,
|
||||
detail: `Contact logged ${contactDays}d ago — still warm.`,
|
||||
});
|
||||
} else if (contactDays >= 30) {
|
||||
score -= 15;
|
||||
signals.push({
|
||||
id: 'contact_stale',
|
||||
delta: -15,
|
||||
detail: `No contact logged in ${contactDays}d — going cold.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stage progress: every step past enquiry signals momentum.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(input.pipelineStage as PipelineStage);
|
||||
if (stageIdx > 0) {
|
||||
const bonus = Math.min(30, stageIdx * 10);
|
||||
score += bonus;
|
||||
signals.push({
|
||||
id: 'stage_progress',
|
||||
delta: +bonus,
|
||||
detail: `Reached ${input.pipelineStage.replace(/_/g, ' ')} stage.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Age penalty for stuck top-of-funnel leads.
|
||||
const firstDays = daysSince(input.dateFirstContact);
|
||||
if (
|
||||
firstDays !== null &&
|
||||
firstDays >= 30 &&
|
||||
(input.pipelineStage === 'enquiry' || input.pipelineStage === 'qualified')
|
||||
) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'stuck_top_funnel',
|
||||
delta: -10,
|
||||
detail: `Deal opened ${firstDays}d ago and still pre-EOI.`,
|
||||
});
|
||||
}
|
||||
|
||||
// EOI in-flight too long.
|
||||
const eoiSentDays = daysSince(input.dateEoiSent);
|
||||
if (
|
||||
eoiSentDays !== null &&
|
||||
eoiSentDays >= 14 &&
|
||||
input.eoiDocStatus !== 'signed' &&
|
||||
!input.dateEoiSigned
|
||||
) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'eoi_awaiting',
|
||||
delta: -10,
|
||||
detail: `EOI awaiting signature for ${eoiSentDays}d.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Reservation signed but deposit not received.
|
||||
const reservationDays = daysSince(input.dateReservationSigned);
|
||||
if (reservationDays !== null && reservationDays >= 21 && !input.dateDepositReceived) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'deposit_pending',
|
||||
delta: -10,
|
||||
detail: `Reservation signed ${reservationDays}d ago but deposit not recorded.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Contract awaiting signature.
|
||||
const contractSentDays = daysSince(input.dateContractSent);
|
||||
if (
|
||||
contractSentDays !== null &&
|
||||
contractSentDays >= 14 &&
|
||||
input.contractDocStatus !== 'signed' &&
|
||||
!input.dateContractSigned
|
||||
) {
|
||||
score -= 10;
|
||||
signals.push({
|
||||
id: 'contract_awaiting',
|
||||
delta: -10,
|
||||
detail: `Contract awaiting signature for ${contractSentDays}d.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Clamp to [0, 100].
|
||||
score = Math.max(0, Math.min(100, score));
|
||||
|
||||
const pulse: DealHealth['pulse'] = score >= 70 ? 'hot' : score >= 40 ? 'warm' : 'cold';
|
||||
return { score, pulse, signals };
|
||||
}
|
||||
@@ -785,20 +785,29 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
// Trigger berth rules
|
||||
void evaluateRule('eoi_sent', interest.id, portId, meta);
|
||||
|
||||
// Advance pipeline stage to eoi_sent (no-op if already further along).
|
||||
void advanceStageIfBehind(interest.id, portId, 'eoi_sent', meta, 'EOI sent for signing');
|
||||
// Advance pipeline stage to eoi (no-op if already further along).
|
||||
// Doc sub-status is set by the webhook receiver when Documenso confirms;
|
||||
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
|
||||
void advanceStageIfBehind(interest.id, portId, 'eoi', meta, 'EOI sent for signing');
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
|
||||
.where(eq(interests.id, interest.id));
|
||||
|
||||
// G-C5: reservation agreements drive the contract_sent stage. The EOI
|
||||
// and contract flows share `sendForSigning`, so we differentiate by
|
||||
// documentType here rather than splitting the entry point.
|
||||
// Reservation agreements drive the reservation stage; the contract
|
||||
// pathway uses its own send call and stamps contractDocStatus.
|
||||
if (doc.documentType === 'reservation_agreement') {
|
||||
void advanceStageIfBehind(
|
||||
interest.id,
|
||||
portId,
|
||||
'contract_sent',
|
||||
'reservation',
|
||||
meta,
|
||||
'Reservation agreement sent',
|
||||
);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ reservationDocStatus: 'sent', updatedAt: new Date() })
|
||||
.where(eq(interests.id, interest.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,17 +897,22 @@ export async function uploadSignedManually(
|
||||
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
|
||||
.set({
|
||||
eoiStatus: 'signed',
|
||||
eoiDocStatus: 'signed',
|
||||
dateEoiSigned: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
|
||||
if (interest) {
|
||||
void evaluateRule('eoi_signed', doc.interestId, portId, meta);
|
||||
|
||||
// Advance to eoi_signed (no-op if already past it).
|
||||
// Stage stays at 'eoi' — sub-status badge flips to "signed".
|
||||
void advanceStageIfBehind(
|
||||
doc.interestId,
|
||||
portId,
|
||||
'eoi_signed',
|
||||
'eoi',
|
||||
meta,
|
||||
'Signed EOI uploaded manually',
|
||||
);
|
||||
@@ -1412,30 +1426,6 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: 'webhook',
|
||||
});
|
||||
|
||||
// G-C5: reservation agreement signing-complete → contract_signed.
|
||||
// Fired here (not below in the eoi-only branch) so contract pipeline
|
||||
// tracks reality the same way EOIs do via the eoi_signed advance.
|
||||
if (doc.documentType === 'reservation_agreement' && doc.interestId) {
|
||||
const systemMeta: AuditMeta = {
|
||||
userId: 'system',
|
||||
portId: doc.portId,
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: 'webhook',
|
||||
};
|
||||
void advanceStageIfBehind(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'contract_signed',
|
||||
systemMeta,
|
||||
'Reservation agreement signed',
|
||||
);
|
||||
// Dynamic import mirrors the eoi_signed pattern below to avoid the
|
||||
// berth-rules-engine module-cycle risk during cold-start.
|
||||
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
|
||||
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Distinguish "we lost the concurrent race" from a real failure —
|
||||
// the loser of the SELECT FOR UPDATE re-check should clean up its
|
||||
@@ -1486,7 +1476,12 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'signed', dateEoiSigned: new Date(), updatedAt: new Date() })
|
||||
.set({
|
||||
eoiStatus: 'signed',
|
||||
eoiDocStatus: 'signed',
|
||||
dateEoiSigned: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
|
||||
if (interest) {
|
||||
@@ -1497,30 +1492,89 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
userAgent: 'webhook',
|
||||
};
|
||||
|
||||
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times
|
||||
// (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the
|
||||
// stage. advanceStageIfBehind handles the pipeline guard internally, but
|
||||
// evaluateRule has no idempotency - skip it if the interest is already at
|
||||
// eoi_signed or beyond to prevent duplicate berth-rule side effects.
|
||||
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple
|
||||
// times. evaluateRule has no idempotency — skip when the interest is
|
||||
// already past the EOI stage so the berth-rule side effect runs once.
|
||||
const currentStageIdx = PIPELINE_STAGES.indexOf(
|
||||
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||
);
|
||||
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||
if (currentStageIdx < eoiSignedIdx) {
|
||||
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
|
||||
if (currentStageIdx <= eoiIdx) {
|
||||
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
|
||||
}
|
||||
|
||||
// Advance to eoi_signed (no-op if interest already past it).
|
||||
// Stage stays at 'eoi' — sub-status flips to signed.
|
||||
void advanceStageIfBehind(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'eoi_signed',
|
||||
'eoi',
|
||||
systemMeta,
|
||||
'EOI signed via Documenso',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update interest if reservation_agreement type — kept out of the
|
||||
// signed-PDF try/catch above so a Documenso PDF-download failure doesn't
|
||||
// also lose the sub-status stamp (which the rep can see immediately on
|
||||
// the interest detail page).
|
||||
if (doc.interestId && doc.documentType === 'reservation_agreement') {
|
||||
const systemMeta: AuditMeta = {
|
||||
userId: 'system',
|
||||
portId: doc.portId,
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: 'webhook',
|
||||
};
|
||||
await db
|
||||
.update(interests)
|
||||
.set({
|
||||
reservationDocStatus: 'signed',
|
||||
dateReservationSigned: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
void advanceStageIfBehind(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'reservation',
|
||||
systemMeta,
|
||||
'Reservation agreement signed',
|
||||
);
|
||||
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
|
||||
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
|
||||
);
|
||||
}
|
||||
|
||||
// Update interest if contract type. Outcome flip to 'won' is a separate
|
||||
// explicit decision so reps can record a contract as signed without
|
||||
// prematurely closing the deal.
|
||||
if (doc.interestId && doc.documentType === 'contract') {
|
||||
const systemMeta: AuditMeta = {
|
||||
userId: 'system',
|
||||
portId: doc.portId,
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: 'webhook',
|
||||
};
|
||||
await db
|
||||
.update(interests)
|
||||
.set({
|
||||
contractDocStatus: 'signed',
|
||||
dateContractSigned: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
void advanceStageIfBehind(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'contract',
|
||||
systemMeta,
|
||||
'Contract signed via Documenso',
|
||||
);
|
||||
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
|
||||
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'completed',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - is_in_eoi_bundle : covered by the interest's EOI signature.
|
||||
*/
|
||||
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
|
||||
@@ -289,6 +289,25 @@ export async function upsertInterestBerthTx(
|
||||
set: setForUpdate,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Auto-promote leadCategory: linking a specific berth means the interest
|
||||
// is now anchored to a real piece of inventory, which is the definition
|
||||
// of `specific_qualified`. Only bumps `general_interest` (or null) —
|
||||
// never demotes `hot_lead` or anything else already past qualified.
|
||||
const isSpecific = row?.isSpecificInterest ?? opts.isSpecificInterest ?? true;
|
||||
if (isSpecific) {
|
||||
await tx
|
||||
.update(interests)
|
||||
.set({ leadCategory: 'specific_qualified' })
|
||||
.where(
|
||||
and(eq(interests.id, interestId), inArray(interests.leadCategory, ['general_interest'])),
|
||||
);
|
||||
// Separately handle the NULL case (Drizzle's `inArray` can't include null).
|
||||
await tx.execute(
|
||||
sql`UPDATE interests SET lead_category = 'specific_qualified' WHERE id = ${interestId} AND lead_category IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
return row!;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,16 @@ import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
|
||||
export type ContactDirection = 'outbound' | 'inbound';
|
||||
|
||||
export type ContactTemplate = 'call' | 'visit' | 'email';
|
||||
|
||||
export interface CreateContactLogInput {
|
||||
interestId: string;
|
||||
occurredAt: Date;
|
||||
channel: ContactChannel;
|
||||
direction: ContactDirection;
|
||||
summary: string;
|
||||
voiceTranscript?: string | null;
|
||||
templateUsed?: ContactTemplate | null;
|
||||
followUpAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -43,6 +47,8 @@ export interface UpdateContactLogInput {
|
||||
channel?: ContactChannel;
|
||||
direction?: ContactDirection;
|
||||
summary?: string;
|
||||
voiceTranscript?: string | null;
|
||||
templateUsed?: ContactTemplate | null;
|
||||
followUpAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -114,6 +120,8 @@ export async function create(
|
||||
channel: input.channel,
|
||||
direction: input.direction,
|
||||
summary: input.summary,
|
||||
voiceTranscript: input.voiceTranscript ?? null,
|
||||
templateUsed: input.templateUsed ?? null,
|
||||
followUpAt: input.followUpAt ?? null,
|
||||
reminderId,
|
||||
createdBy: userId,
|
||||
@@ -199,6 +207,8 @@ export async function update(
|
||||
...(input.channel !== undefined && { channel: input.channel }),
|
||||
...(input.direction !== undefined && { direction: input.direction }),
|
||||
...(input.summary !== undefined && { summary: input.summary }),
|
||||
...(input.voiceTranscript !== undefined && { voiceTranscript: input.voiceTranscript }),
|
||||
...(input.templateUsed !== undefined && { templateUsed: input.templateUsed }),
|
||||
followUpAt: newFollowUpAt,
|
||||
reminderId,
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -2,14 +2,16 @@ import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { getPortReminderConfig } from '@/lib/services/port-config';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||
@@ -540,6 +542,32 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
|
||||
|
||||
// Activity log entries in the last 7 days — surfaces "rep is engaged"
|
||||
// as a separate signal in the deal-health pulse beyond the coarse
|
||||
// dateLastContact bump.
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
|
||||
const [{ count: recentActivityCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestContactLog)
|
||||
.where(
|
||||
and(
|
||||
eq(interestContactLog.interestId, id),
|
||||
sql`${interestContactLog.occurredAt} >= ${sevenDaysAgo}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve the assignee's display name for the header chip — falling back
|
||||
// to the raw ID is fine if the user record is missing (deleted/disabled).
|
||||
let assignedToName: string | null = null;
|
||||
if (interest.assignedTo) {
|
||||
const [profile] = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, interest.assignedTo))
|
||||
.limit(1);
|
||||
assignedToName = profile?.displayName ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
...interest,
|
||||
clientName: clientRow?.fullName ?? null,
|
||||
@@ -554,6 +582,8 @@ export async function getInterestById(id: string, portId: string) {
|
||||
notesCount,
|
||||
recentNote: recentNote ?? null,
|
||||
activeReminderCount,
|
||||
assignedToName,
|
||||
recentActivityCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -586,12 +616,23 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
const resolvedReminderDays =
|
||||
interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null);
|
||||
|
||||
// Auto-assign to the port's default owner when the caller omits assignedTo.
|
||||
// Setting is stored as `{ userId: "..." }` so other surfaces can extend it
|
||||
// with round-robin / quota rules later without breaking this code path.
|
||||
let resolvedAssignedTo = interestData.assignedTo ?? null;
|
||||
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
|
||||
const defaultOwner = await getSetting('default_new_interest_owner', portId);
|
||||
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
||||
if (v?.userId) resolvedAssignedTo = v.userId;
|
||||
}
|
||||
|
||||
const result = await withTransaction(async (tx) => {
|
||||
const [interest] = await tx
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
...interestData,
|
||||
assignedTo: resolvedAssignedTo,
|
||||
reminderEnabled: resolvedReminderEnabled,
|
||||
reminderDays: resolvedReminderDays,
|
||||
leadCategory: resolvedLeadCategory,
|
||||
@@ -734,6 +775,36 @@ export async function updateInterest(
|
||||
changedFields: Object.keys(diff),
|
||||
});
|
||||
|
||||
// Owner change → notify the new assignee. We skip self-reassign so a rep
|
||||
// re-claiming their own deal doesn't get a noise notification.
|
||||
if (
|
||||
'assignedTo' in data &&
|
||||
data.assignedTo &&
|
||||
data.assignedTo !== existing.assignedTo &&
|
||||
data.assignedTo !== meta.userId
|
||||
) {
|
||||
const [clientRow] = await db
|
||||
.select({ fullName: clients.fullName })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, existing.clientId))
|
||||
.limit(1);
|
||||
const clientLabel = clientRow?.fullName ?? 'a client';
|
||||
|
||||
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
||||
createNotification({
|
||||
portId,
|
||||
userId: data.assignedTo!,
|
||||
type: 'interest_assigned',
|
||||
title: 'New deal assigned to you',
|
||||
description: `${clientLabel} — ${existing.pipelineStage.replace(/_/g, ' ')}`,
|
||||
link: `/interests/${id}` as never,
|
||||
entityType: 'interest',
|
||||
entityId: id,
|
||||
dedupeKey: `interest_assigned:${id}:${data.assignedTo}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
@@ -753,14 +824,18 @@ export async function changeInterestStage(
|
||||
throw new NotFoundError('Interest');
|
||||
}
|
||||
|
||||
// Plan: yachtId required to leave stage=open
|
||||
if (existing.pipelineStage === 'open' && data.pipelineStage !== 'open' && !existing.yachtId) {
|
||||
throw new ValidationError('yachtId is required before leaving stage=open');
|
||||
// Plan: yachtId required to leave the initial enquiry stage
|
||||
if (
|
||||
existing.pipelineStage === 'enquiry' &&
|
||||
data.pipelineStage !== 'enquiry' &&
|
||||
!existing.yachtId
|
||||
) {
|
||||
throw new ValidationError('yachtId is required before leaving stage=enquiry');
|
||||
}
|
||||
|
||||
// Block egregious skips. The transition table allows reasonable forward
|
||||
// jumps (e.g. open → eoi_sent) while rejecting things like completed → open
|
||||
// or open → contract_signed. Same-stage no-ops are allowed.
|
||||
// jumps (e.g. enquiry → eoi) while rejecting things like contract → enquiry.
|
||||
// Same-stage no-ops are allowed.
|
||||
// Override (sales-rep manual fix) bypasses the table — the route handler
|
||||
// gates this on the `interests.override_stage` permission and requires
|
||||
// a reason, recorded in the audit log below.
|
||||
@@ -788,11 +863,13 @@ export async function changeInterestStage(
|
||||
// "deposit landed yesterday"); we still default to now when omitted.
|
||||
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
|
||||
const milestoneUpdates: Record<string, unknown> = {};
|
||||
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = milestoneDate;
|
||||
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = milestoneDate;
|
||||
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = milestoneDate;
|
||||
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = milestoneDate;
|
||||
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = milestoneDate;
|
||||
// For doc-bearing stages (eoi/reservation/contract) the milestone date is
|
||||
// owned by the doc-send/sign flow, not the stage move — these only fire
|
||||
// when the rep stamps a date manually via override.
|
||||
if (data.pipelineStage === 'eoi') milestoneUpdates.dateEoiSent = milestoneDate;
|
||||
if (data.pipelineStage === 'reservation') milestoneUpdates.dateReservationSigned = milestoneDate;
|
||||
if (data.pipelineStage === 'deposit_paid') milestoneUpdates.dateDepositReceived = milestoneDate;
|
||||
if (data.pipelineStage === 'contract') milestoneUpdates.dateContractSent = milestoneDate;
|
||||
if (Object.keys(milestoneUpdates).length > 0) {
|
||||
await db
|
||||
.update(interests)
|
||||
|
||||
@@ -675,13 +675,16 @@ export async function recordPayment(
|
||||
|
||||
// Deposit invoices linked to a sales interest auto-advance the pipeline.
|
||||
// Only advances forward - no-op if the interest has already moved past
|
||||
// deposit_10pct (e.g. straight-to-contract flows).
|
||||
// deposit_paid (e.g. straight-to-contract flows). NOTE: the v1 sales
|
||||
// refactor introduces a separate `payments` table that supersedes invoice
|
||||
// tracking for the deposit stage; this block stays wired for legacy
|
||||
// invoices but new flows record payments via that pathway instead.
|
||||
if (updated.kind === 'deposit' && updated.interestId) {
|
||||
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehind(
|
||||
updated.interestId,
|
||||
portId,
|
||||
'deposit_10pct',
|
||||
'deposit_paid',
|
||||
meta,
|
||||
`Deposit invoice ${existing.invoiceNumber} paid`,
|
||||
);
|
||||
|
||||
226
src/lib/services/payments.service.ts
Normal file
226
src/lib/services/payments.service.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Payment-records service. The CRM does NOT generate invoices — banks invoice
|
||||
* clients directly. We record that money was received (or refunded) with an
|
||||
* optional uploaded receipt for audit purposes.
|
||||
*
|
||||
* Auto-advance: when the running deposit total (SUM where payment_type='deposit'
|
||||
* minus SUM of refunds) reaches `interests.depositExpectedAmount`, the pipeline
|
||||
* stage moves to 'deposit_paid' (no-op if already past).
|
||||
*/
|
||||
|
||||
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, payments } from '@/lib/db/schema';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreatePaymentInput, UpdatePaymentInput } from '@/lib/validators/payments';
|
||||
|
||||
// ─── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** All payments for a single interest, newest received first. */
|
||||
export async function listPaymentsForInterest(interestId: string, portId: string) {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(and(eq(payments.interestId, interestId), eq(payments.portId, portId)))
|
||||
.orderBy(desc(payments.receivedAt), asc(payments.id));
|
||||
}
|
||||
|
||||
/** Net deposit total for an interest. `deposit` rows add; `refund` rows
|
||||
* subtract (their `amount` may be either positive or already negative — we
|
||||
* always treat refunds as deductions to match the UI convention). */
|
||||
export async function getDepositTotalForInterest(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<{ total: string; currency: string }> {
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentType: payments.paymentType,
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
})
|
||||
.from(payments)
|
||||
.where(
|
||||
and(
|
||||
eq(payments.interestId, interestId),
|
||||
eq(payments.portId, portId),
|
||||
sql`${payments.paymentType} IN ('deposit', 'refund')`,
|
||||
),
|
||||
);
|
||||
|
||||
// Use BigInt-ish accumulator via Number — amounts are EUR scale; we don't
|
||||
// need cent-precise math for the auto-advance gate, but we DO normalize the
|
||||
// sign of refunds so a refund stored as "+200" still subtracts.
|
||||
let net = 0;
|
||||
let currency = 'EUR';
|
||||
for (const row of rows) {
|
||||
const n = Number(row.amount);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
currency = row.currency;
|
||||
net += row.paymentType === 'refund' ? -Math.abs(n) : n;
|
||||
}
|
||||
return { total: net.toFixed(2), currency };
|
||||
}
|
||||
|
||||
// ─── Writes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createPayment(portId: string, data: CreatePaymentInput, meta: AuditMeta) {
|
||||
// Resolve interest + sanity-check it belongs to this port.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, data.interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true, clientId: true, depositExpectedAmount: true, pipelineStage: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
const amountNum = Number(data.amount);
|
||||
if (!Number.isFinite(amountNum) || amountNum === 0) {
|
||||
throw new ValidationError('amount must be a non-zero numeric value');
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(payments)
|
||||
.values({
|
||||
portId,
|
||||
interestId: data.interestId,
|
||||
clientId: interest.clientId,
|
||||
paymentType: data.paymentType,
|
||||
amount: data.amount,
|
||||
currency: data.currency,
|
||||
receivedAt: new Date(data.receivedAt),
|
||||
receiptFileId: data.receiptFileId ?? null,
|
||||
notes: data.notes ?? null,
|
||||
recordedBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'payment',
|
||||
entityId: row!.id,
|
||||
newValue: {
|
||||
interestId: data.interestId,
|
||||
paymentType: data.paymentType,
|
||||
amount: data.amount,
|
||||
currency: data.currency,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'payment:created', {
|
||||
paymentId: row!.id,
|
||||
interestId: data.interestId,
|
||||
paymentType: data.paymentType,
|
||||
});
|
||||
|
||||
// Auto-advance: when the running deposit total reaches the expected amount,
|
||||
// promote the stage to 'deposit_paid'. Dynamic import keeps the
|
||||
// payments ↔ interests cycle one-way at module-load time.
|
||||
if (data.paymentType === 'deposit' || data.paymentType === 'refund') {
|
||||
const { total } = await getDepositTotalForInterest(data.interestId, portId);
|
||||
const expected = interest.depositExpectedAmount ? Number(interest.depositExpectedAmount) : null;
|
||||
if (expected !== null && Number.isFinite(expected) && Number(total) >= expected) {
|
||||
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehind(
|
||||
data.interestId,
|
||||
portId,
|
||||
'deposit_paid',
|
||||
meta,
|
||||
`Deposit total (${total} ${data.currency}) reached expected amount`,
|
||||
);
|
||||
|
||||
// Stamp dateDepositReceived if not already set so the timeline shows
|
||||
// when the threshold was met (not the date of the first payment row).
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ dateDepositReceived: new Date(), updatedAt: new Date() })
|
||||
.where(eq(interests.id, data.interestId));
|
||||
|
||||
// Berth rule fires via the same hook the legacy invoices.ts path uses.
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
void evaluateRule('deposit_received', data.interestId, portId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updatePayment(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdatePaymentInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.payments.findFirst({
|
||||
where: and(eq(payments.id, id), eq(payments.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Payment');
|
||||
|
||||
const next: Record<string, unknown> = {};
|
||||
if (data.paymentType !== undefined) next.paymentType = data.paymentType;
|
||||
if (data.amount !== undefined) next.amount = data.amount;
|
||||
if (data.currency !== undefined) next.currency = data.currency;
|
||||
if (data.receivedAt !== undefined) next.receivedAt = new Date(data.receivedAt);
|
||||
if (data.receiptFileId !== undefined) next.receiptFileId = data.receiptFileId;
|
||||
if (data.notes !== undefined) next.notes = data.notes;
|
||||
|
||||
const [updated] = await db
|
||||
.update(payments)
|
||||
.set(next)
|
||||
.where(and(eq(payments.id, id), eq(payments.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'payment',
|
||||
entityId: id,
|
||||
oldValue: {
|
||||
paymentType: existing.paymentType,
|
||||
amount: existing.amount,
|
||||
currency: existing.currency,
|
||||
},
|
||||
newValue: next,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function deletePayment(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.payments.findFirst({
|
||||
where: and(eq(payments.id, id), eq(payments.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Payment');
|
||||
|
||||
await db.delete(payments).where(and(eq(payments.id, id), eq(payments.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'payment',
|
||||
entityId: id,
|
||||
oldValue: {
|
||||
paymentType: existing.paymentType,
|
||||
amount: existing.amount,
|
||||
currency: existing.currency,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
278
src/lib/services/qualification.service.ts
Normal file
278
src/lib/services/qualification.service.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Qualification-criteria service. Per-port admins configure the criteria that
|
||||
* a deal must satisfy to be considered "qualified" (the gate between enquiry
|
||||
* and the rest of the pipeline). Per-interest state is captured separately
|
||||
* so changing the port's criteria doesn't retroactively affect existing
|
||||
* deals.
|
||||
*
|
||||
* The "fully qualified" derivation drives the soft hint on the interest
|
||||
* detail page that an enquiry is ready to advance.
|
||||
*/
|
||||
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type {
|
||||
CreateQualificationCriterionInput,
|
||||
SetInterestQualificationInput,
|
||||
UpdateQualificationCriterionInput,
|
||||
} from '@/lib/validators/qualification';
|
||||
|
||||
// ─── Port-scoped criterion config (admin) ───────────────────────────────────
|
||||
|
||||
export async function listCriteriaForPort(portId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(qualificationCriteria)
|
||||
.where(eq(qualificationCriteria.portId, portId))
|
||||
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
|
||||
}
|
||||
|
||||
export async function createCriterion(
|
||||
portId: string,
|
||||
data: CreateQualificationCriterionInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
// Unique (portId, key) is enforced at DB level, but doing the check here
|
||||
// surfaces a friendlier 409 with the offending key.
|
||||
const existing = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.key, data.key)),
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictError(`A criterion with key "${data.key}" already exists for this port`);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(qualificationCriteria)
|
||||
.values({
|
||||
portId,
|
||||
key: data.key,
|
||||
label: data.label,
|
||||
description: data.description ?? null,
|
||||
enabled: data.enabled,
|
||||
displayOrder: data.displayOrder,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'qualification_criterion',
|
||||
entityId: row!.id,
|
||||
newValue: { key: data.key, label: data.label, enabled: data.enabled },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updateCriterion(
|
||||
id: string,
|
||||
portId: string,
|
||||
data: UpdateQualificationCriterionInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const existing = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Qualification criterion');
|
||||
|
||||
const next: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (data.label !== undefined) next.label = data.label;
|
||||
if (data.description !== undefined) next.description = data.description;
|
||||
if (data.enabled !== undefined) next.enabled = data.enabled;
|
||||
if (data.displayOrder !== undefined) next.displayOrder = data.displayOrder;
|
||||
|
||||
const [updated] = await db
|
||||
.update(qualificationCriteria)
|
||||
.set(next)
|
||||
.where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'qualification_criterion',
|
||||
entityId: id,
|
||||
oldValue: { label: existing.label, enabled: existing.enabled },
|
||||
newValue: next,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
export async function deleteCriterion(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)),
|
||||
});
|
||||
if (!existing) throw new NotFoundError('Qualification criterion');
|
||||
|
||||
// Per-interest state rows reference the key, not the criterion id, so they
|
||||
// survive a criterion deletion as historical noise. UI hides rows whose key
|
||||
// no longer matches an active criterion.
|
||||
await db
|
||||
.delete(qualificationCriteria)
|
||||
.where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)));
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'delete',
|
||||
entityType: 'qualification_criterion',
|
||||
entityId: id,
|
||||
oldValue: { key: existing.key, label: existing.label },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─── Per-interest state ─────────────────────────────────────────────────────
|
||||
|
||||
export interface QualificationRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
confirmed: boolean;
|
||||
confirmedAt: Date | null;
|
||||
confirmedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The qualification state for a specific interest, joined with the port's
|
||||
* current criterion definitions. Returns only currently-enabled criteria —
|
||||
* disabled ones are hidden from the rep but their state rows are preserved
|
||||
* in the DB for audit.
|
||||
*/
|
||||
export async function listInterestQualifications(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<QualificationRow[]> {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
const criteria = await db
|
||||
.select()
|
||||
.from(qualificationCriteria)
|
||||
.where(and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.enabled, true)))
|
||||
.orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt));
|
||||
|
||||
const states = await db
|
||||
.select()
|
||||
.from(interestQualifications)
|
||||
.where(eq(interestQualifications.interestId, interestId));
|
||||
const stateByKey = new Map(states.map((s) => [s.criterionKey, s] as const));
|
||||
|
||||
return criteria.map((c) => {
|
||||
const s = stateByKey.get(c.key);
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
displayOrder: c.displayOrder,
|
||||
confirmed: s?.confirmed ?? false,
|
||||
confirmedAt: s?.confirmedAt ?? null,
|
||||
confirmedBy: s?.confirmedBy ?? null,
|
||||
notes: s?.notes ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single criterion's confirmed-state for an interest. Stamping the
|
||||
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
|
||||
* audit record — the caller can't backdate it.
|
||||
*/
|
||||
export async function setInterestQualification(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
data: SetInterestQualificationInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
// Refuse keys the port doesn't have a criterion for — keeps state rows
|
||||
// referentially consistent with the visible config.
|
||||
const criterion = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(
|
||||
eq(qualificationCriteria.portId, portId),
|
||||
eq(qualificationCriteria.key, data.criterionKey),
|
||||
),
|
||||
});
|
||||
if (!criterion) throw new NotFoundError('Qualification criterion');
|
||||
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(interestQualifications)
|
||||
.values({
|
||||
interestId,
|
||||
criterionKey: data.criterionKey,
|
||||
confirmed: data.confirmed,
|
||||
confirmedAt: data.confirmed ? now : null,
|
||||
confirmedBy: data.confirmed ? meta.userId : null,
|
||||
notes: data.notes ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [interestQualifications.interestId, interestQualifications.criterionKey],
|
||||
set: {
|
||||
confirmed: data.confirmed,
|
||||
confirmedAt: data.confirmed ? now : null,
|
||||
confirmedBy: data.confirmed ? meta.userId : null,
|
||||
notes: data.notes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'interest_qualification',
|
||||
entityId: `${interestId}:${data.criterionKey}`,
|
||||
newValue: { confirmed: data.confirmed, key: data.criterionKey },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'interest:qualificationChanged', {
|
||||
interestId,
|
||||
criterionKey: data.criterionKey,
|
||||
confirmed: data.confirmed,
|
||||
});
|
||||
|
||||
return listInterestQualifications(interestId, portId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when every enabled criterion for the port is confirmed for
|
||||
* the given interest. Used by the UI to surface the "ready to qualify" hint
|
||||
* and by the auto-advance helper to soft-suggest moving to 'qualified'.
|
||||
*/
|
||||
export async function isInterestFullyQualified(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await listInterestQualifications(interestId, portId);
|
||||
if (rows.length === 0) return false;
|
||||
return rows.every((r) => r.confirmed);
|
||||
}
|
||||
@@ -113,13 +113,13 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['labels', 'categories', 'classification'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/profile',
|
||||
href: '/:portSlug/settings',
|
||||
label: 'Notification preferences',
|
||||
category: 'settings',
|
||||
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/profile',
|
||||
href: '/:portSlug/settings',
|
||||
label: 'My profile & preferences',
|
||||
category: 'settings',
|
||||
keywords: [
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface ClientResult {
|
||||
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
|
||||
archivedAt: string | null;
|
||||
relatedVia?: RelatedVia | null;
|
||||
/** Short label for which field matched ("name", "email", "phone", "trigram", "expansion"). Used by the dropdown to render "matched on X". */
|
||||
matchedOn?: string | null;
|
||||
}
|
||||
|
||||
export interface ResidentialClientResult {
|
||||
@@ -451,13 +453,25 @@ async function searchClients(
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return Array.from(rows).map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
matchedContact: r.matched_value ?? null,
|
||||
matchedContactChannel: r.matched_channel ?? null,
|
||||
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
|
||||
}));
|
||||
return Array.from(rows).map((r) => {
|
||||
// Tag the rank tier we picked back as a human-readable label so the
|
||||
// dropdown can render "matched on name" / "matched on email" without
|
||||
// the UI re-doing the comparison. Mirrors the CASE in `rank` above.
|
||||
let matchedOn: string | null = null;
|
||||
if (r.rank >= 80) matchedOn = 'name';
|
||||
else if (r.rank >= 70) matchedOn = 'name';
|
||||
else if (r.rank >= 60) matchedOn = r.matched_channel ?? 'contact';
|
||||
else if (r.rank >= 55) matchedOn = 'phone';
|
||||
else if (r.rank >= 30) matchedOn = 'similar name';
|
||||
return {
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
matchedContact: r.matched_value ?? null,
|
||||
matchedContactChannel: r.matched_channel ?? null,
|
||||
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
|
||||
matchedOn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function searchResidentialClients(
|
||||
@@ -1870,17 +1884,38 @@ export async function search(
|
||||
// who search "A10" see the linked interests/clients/yachts/companies
|
||||
// surface alongside the berth. See `expandGraph` docstring for the
|
||||
// relationship map and per-bucket caps.
|
||||
const expanded = await expandGraph(
|
||||
portId,
|
||||
{
|
||||
berthIds: berths.map((b) => b.id),
|
||||
interestIds: interests.map((i) => i.id),
|
||||
clientIds: clients.map((c) => c.id),
|
||||
yachtIds: yachts.map((y) => y.id),
|
||||
companyIds: companies.map((c) => c.id),
|
||||
},
|
||||
limit,
|
||||
);
|
||||
//
|
||||
// Latency optimization: when every relationship-bearing bucket already
|
||||
// has the maximum number of direct matches the dropdown will render,
|
||||
// graph expansion only adds rows that get truncated downstream — skip
|
||||
// the (cross-table-heavy) expansion query entirely. Saves the biggest
|
||||
// single SQL call in the search path on common-term queries.
|
||||
const allBucketsFull =
|
||||
clients.length >= limit &&
|
||||
yachts.length >= limit &&
|
||||
companies.length >= limit &&
|
||||
interests.length >= limit &&
|
||||
berths.length >= limit;
|
||||
|
||||
const expanded = allBucketsFull
|
||||
? {
|
||||
interests: [] as InterestResult[],
|
||||
clients: [] as ClientResult[],
|
||||
yachts: [] as YachtResult[],
|
||||
companies: [] as CompanyResult[],
|
||||
berths: [] as BerthResult[],
|
||||
}
|
||||
: await expandGraph(
|
||||
portId,
|
||||
{
|
||||
berthIds: berths.map((b) => b.id),
|
||||
interestIds: interests.map((i) => i.id),
|
||||
clientIds: clients.map((c) => c.id),
|
||||
yachtIds: yachts.map((y) => y.id),
|
||||
companyIds: companies.map((c) => c.id),
|
||||
},
|
||||
limit,
|
||||
);
|
||||
|
||||
const apply = <T extends { id: string }>(rows: T[]) =>
|
||||
applyAffinity(rows, opts.recentlyTouchedIds);
|
||||
|
||||
Reference in New Issue
Block a user