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

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