feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
/**
|
|
|
|
|
* Berth recommender (plan §4.4 + §13).
|
|
|
|
|
*
|
|
|
|
|
* Pure SQL ranking - no AI. The single CTE chain:
|
|
|
|
|
*
|
|
|
|
|
* 1. interest_input - the desired dimensions + port for this interest
|
|
|
|
|
* 2. feasible - berths in the port that can fit the yacht
|
|
|
|
|
* (length/width/draft >= desired) AND don't exceed
|
|
|
|
|
* the configured max-oversize percentage
|
|
|
|
|
* 3. tier_inputs - per-berth aggregates over interest_berths +
|
|
|
|
|
* interests: active count, lost count, max stage
|
|
|
|
|
* among active interests, latest fall-through
|
|
|
|
|
* timestamp, total historical interest count,
|
|
|
|
|
* EOI-signed count
|
|
|
|
|
* 4. classified - assign tier A/B/C/D
|
|
|
|
|
*
|
|
|
|
|
* The feasible berths are returned to JavaScript for fit-score + heat
|
|
|
|
|
* calculation - keeping that out of SQL lets per-port admins tune the
|
|
|
|
|
* heat weights without a code deploy.
|
|
|
|
|
*
|
|
|
|
|
* Heat scoring (only relevant for tier B; berths surface back into the
|
|
|
|
|
* recommender pool after a fall-through):
|
|
|
|
|
* - recency : how recently did the last interest fall through
|
|
|
|
|
* - furthest stage : how close did the prior interest get to closing
|
|
|
|
|
* - historical interest : how often does this berth attract interest
|
|
|
|
|
* - historical EOI count : how often does interest convert to EOI
|
|
|
|
|
*
|
|
|
|
|
* Per-port settings (defaults below) drive the cap, fall-through policy,
|
|
|
|
|
* heat weights, and tier-D visibility.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
|
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { systemSettings } from '@/lib/db/schema/system';
|
|
|
|
|
import { interests } from '@/lib/db/schema/interests';
|
feat(errors): platform-wide request ids + error codes + admin inspector
End-to-end error-handling overhaul. A user hitting any failure now sees
a plain-text message + stable error code + reference id. A super admin
can paste the id into /admin/errors/<id> for the full request shape,
sanitized body, error stack, and a heuristic likely-cause hint.
REQUEST CONTEXT (AsyncLocalStorage)
- src/lib/request-context.ts mints a per-request frame carrying
requestId + portId + userId + method + path + start timestamp.
- withAuth wraps every authenticated handler in runWithRequestContext
and accepts an upstream X-Request-Id header (validated shape) or
generates a fresh UUID. The id ALWAYS leaves on the X-Request-Id
response header, including early-return 401/403/4xx paths.
- Pino logger reads from the same context via mixin — every log
line emitted during the request automatically carries the ids
with no per-call threading.
ERROR CODE REGISTRY
- src/lib/error-codes.ts defines stable DOMAIN_REASON codes with
HTTP status + plain-text user-facing message (no jargon, written
for the rep on the phone with a customer).
- New CodedError class wraps a registered code + optional
internalMessage (admin-only — never sent to client).
- Existing AppError subclasses got plain-text default rewrites so
legacy throw sites improve immediately without migration.
- High-impact services migrated to specific codes:
expenses (RECEIPT_REQUIRED, INVOICE_LINKED), interest-berths
(CROSS_PORT_LINK_REJECTED), berth-pdf (PDF_MAGIC_BYTE / PDF_EMPTY /
PDF_TOO_LARGE / VERSION_ALREADY_CURRENT), recommender
(INTEREST_PORT_MISMATCH).
ERROR ENVELOPE
- errorResponse always sets X-Request-Id header + requestId field.
- 5xx responses include a "Quote error ID …" friendly line.
- 4xx kept clean (validation, permission, not-found don't pollute
the inspector — they're already in audit log).
PERSISTENCE (error_events table, migration 0040)
- One row per 5xx, keyed on requestId, with method/path/status/error
name+message/stack head (4KB cap)/sanitized body excerpt (1KB cap;
password/token/secret/etc keys redacted)/duration/IP/UA/metadata.
- captureErrorEvent extracts Postgres SQLSTATE/severity/cause.code
so the classifier can recognize FK / unique / NOT NULL / schema-
drift violations.
- Failure to persist is logged-not-thrown.
LIKELY-CULPRIT CLASSIFIER (src/lib/error-classifier.ts)
- 4-pass heuristic (first match wins):
1. Postgres SQLSTATE → human reason (23503 FK, 23505 unique,
42703 schema drift, 53300 connection limit, …)
2. Error class name (AbortError, TimeoutError, FetchError,
ZodError)
3. Stack-path patterns (/lib/storage/, /lib/email/, documenso,
openai|claude, /queue/workers/)
4. Free-text message keywords (econnrefused, rate limit, timeout,
unauthorized|invalid api key)
- Returns { label, hint, subsystem } for the inspector badge.
CLIENT SIDE
- apiFetch throws structured ApiError with message + code + requestId
+ details + retryAfter.
- toastError() helper renders the standard 3-line toast:
plain message / Error code: X / Reference ID: Y [Copy ID].
ADMIN INSPECTOR
- /<port>/admin/errors lists captured 5xx with status badge + path +
likely-culprit badge + truncated message + reference id. Filter by
status code; auto-refresh via TanStack Query.
- /<port>/admin/errors/<requestId> deep-dive: request shape, full
error name+message+stack, sanitized body excerpt, raw metadata,
registered-code lookup (so admin can compare to what user saw),
likely-culprit hint with subsystem tag.
- /<port>/admin/errors/codes is the in-app code reference page —
every registered code grouped by domain prefix, searchable, with
HTTP status + user message inline. Linked from inspector header
so admins can flip to it while triaging.
- Permission: admin.view_audit_log. Super admins see all ports;
regular admins port-scoped.
- system-monitoring dashboard now surfaces error_events alongside
permission_denied audit + queue failed jobs (RecentError gains
source: 'request' variant).
DOCS
- docs/error-handling.md walks through coded errors, plain-text
message guidelines, client toasting, admin inspector usage,
persistence rules, classifier internals, pruning, and the
legacy → CodedError migration path.
MIGRATION SAFETY
- Audit confirmed all 41 migrations (0000-0040) apply cleanly in
journal order against an empty DB. 0040 references ports(id)
which exists from 0000. 0035/0038 don't deadlock under sequential
psql -f. Removed redundant idx_ds_sent_by from 0038 (created in
0037).
Tests: 1168/1168 vitest passing. tsc clean.
- security-error-responses tests updated for plain-text messages
+ new optional response keys (code/requestId/message).
- berth-pdf-versions tests assert stable error codes via
toMatchObject({ code }) rather than message regex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:12:59 +02:00
|
|
|
import { CodedError } from '@/lib/errors';
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
|
|
|
|
|
// ─── Settings ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface RecommenderSettings {
|
|
|
|
|
maxOversizePct: number;
|
|
|
|
|
topNDefault: number;
|
|
|
|
|
fallthroughPolicy: 'immediate_with_heat' | 'cooldown' | 'never_auto_recommend';
|
|
|
|
|
fallthroughCooldownDays: number;
|
|
|
|
|
heatWeightRecency: number;
|
|
|
|
|
heatWeightFurthestStage: number;
|
|
|
|
|
heatWeightInterestCount: number;
|
|
|
|
|
heatWeightEoiCount: number;
|
|
|
|
|
tierLadderHideLateStage: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const DEFAULT_RECOMMENDER_SETTINGS: RecommenderSettings = {
|
|
|
|
|
maxOversizePct: 30,
|
|
|
|
|
topNDefault: 8,
|
|
|
|
|
fallthroughPolicy: 'immediate_with_heat',
|
|
|
|
|
fallthroughCooldownDays: 30,
|
|
|
|
|
heatWeightRecency: 30,
|
|
|
|
|
heatWeightFurthestStage: 40,
|
|
|
|
|
heatWeightInterestCount: 15,
|
|
|
|
|
heatWeightEoiCount: 15,
|
|
|
|
|
tierLadderHideLateStage: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SETTINGS_KEYS = {
|
|
|
|
|
maxOversizePct: 'recommender_max_oversize_pct',
|
|
|
|
|
topNDefault: 'recommender_top_n_default',
|
|
|
|
|
fallthroughPolicy: 'fallthrough_policy',
|
|
|
|
|
fallthroughCooldownDays: 'fallthrough_cooldown_days',
|
|
|
|
|
heatWeightRecency: 'heat_weight_recency',
|
|
|
|
|
heatWeightFurthestStage: 'heat_weight_furthest_stage',
|
|
|
|
|
heatWeightInterestCount: 'heat_weight_interest_count',
|
|
|
|
|
heatWeightEoiCount: 'heat_weight_eoi_count',
|
|
|
|
|
tierLadderHideLateStage: 'tier_ladder_hide_late_stage',
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Reads recommender settings for the port, layered over the defaults.
|
|
|
|
|
* Per-port row wins; null portId row is the global fallback; defaults
|
|
|
|
|
* cover anything still missing.
|
|
|
|
|
*/
|
|
|
|
|
export async function loadRecommenderSettings(portId: string): Promise<RecommenderSettings> {
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({ key: systemSettings.key, value: systemSettings.value, portId: systemSettings.portId })
|
|
|
|
|
.from(systemSettings)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
inArray(systemSettings.key, Object.values(SETTINGS_KEYS)),
|
|
|
|
|
sql`(${systemSettings.portId} = ${portId} OR ${systemSettings.portId} IS NULL)`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const portRows = new Map<string, unknown>();
|
|
|
|
|
const globalRows = new Map<string, unknown>();
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
if (r.portId === portId) portRows.set(r.key, r.value);
|
|
|
|
|
else if (r.portId === null) globalRows.set(r.key, r.value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pick = <T>(key: string, parse: (v: unknown) => T | null, fallback: T): T => {
|
|
|
|
|
const portVal = portRows.has(key) ? parse(portRows.get(key)) : null;
|
|
|
|
|
if (portVal !== null) return portVal;
|
|
|
|
|
const globalVal = globalRows.has(key) ? parse(globalRows.get(key)) : null;
|
|
|
|
|
if (globalVal !== null) return globalVal;
|
|
|
|
|
return fallback;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const asNumber = (v: unknown): number | null => {
|
|
|
|
|
if (v === null || v === undefined) return null;
|
|
|
|
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
|
|
|
if (typeof v === 'string') {
|
|
|
|
|
const n = parseFloat(v);
|
|
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).
DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
partial WHERE archived_at IS NULL — clients, interests, yachts, and
both residential tables. Smaller, faster planner choice for the
dominant list-query shape.
Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
before landing on the audit row (the surrounding clientId check was
already port-scoped; interestId pollution was the gap).
Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
gates on the matching resource permission (clients/interests/berths/
yachts/companies). Fixes the cross-resource gap where a user with
clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
already gated; remove was not).
Service polish:
- berth-recommender accepts string-shaped JSONB booleans
('true'/'false') so admin UIs that wrap values as strings don't
silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
captured baseY rather than reading mutating doc.y after rect+stroke.
Headers no longer drift on the first receipt page after a soft page
break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
them so partial silent drops are observable (was invisible because
the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
invariant + the explicit invalidation hook.
UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
- client-yachts-tab passes { type: 'client', id: clientId }
- interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
the selected client is a member (fetches client.companies and feeds
YachtPicker an array filter). Plus an inline "Add new" button that
opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
semantics.
BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).
Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00
|
|
|
const asBool = (v: unknown): boolean | null => {
|
|
|
|
|
if (typeof v === 'boolean') return v;
|
|
|
|
|
// Some admin UIs (or older settings rows) persist booleans as the
|
|
|
|
|
// strings "true" / "false" inside the JSONB blob. Without this
|
|
|
|
|
// tolerant parse, a per-port override quietly falls through to the
|
|
|
|
|
// default and the admin's tuning has no effect.
|
|
|
|
|
if (typeof v === 'string') {
|
|
|
|
|
if (v === 'true') return true;
|
|
|
|
|
if (v === 'false') return false;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
const asPolicy = (v: unknown): RecommenderSettings['fallthroughPolicy'] | null => {
|
|
|
|
|
if (v === 'immediate_with_heat' || v === 'cooldown' || v === 'never_auto_recommend') {
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
maxOversizePct: pick(
|
|
|
|
|
SETTINGS_KEYS.maxOversizePct,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.maxOversizePct,
|
|
|
|
|
),
|
|
|
|
|
topNDefault: pick(
|
|
|
|
|
SETTINGS_KEYS.topNDefault,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.topNDefault,
|
|
|
|
|
),
|
|
|
|
|
fallthroughPolicy: pick(
|
|
|
|
|
SETTINGS_KEYS.fallthroughPolicy,
|
|
|
|
|
asPolicy,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.fallthroughPolicy,
|
|
|
|
|
),
|
|
|
|
|
fallthroughCooldownDays: pick(
|
|
|
|
|
SETTINGS_KEYS.fallthroughCooldownDays,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.fallthroughCooldownDays,
|
|
|
|
|
),
|
|
|
|
|
heatWeightRecency: pick(
|
|
|
|
|
SETTINGS_KEYS.heatWeightRecency,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightRecency,
|
|
|
|
|
),
|
|
|
|
|
heatWeightFurthestStage: pick(
|
|
|
|
|
SETTINGS_KEYS.heatWeightFurthestStage,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightFurthestStage,
|
|
|
|
|
),
|
|
|
|
|
heatWeightInterestCount: pick(
|
|
|
|
|
SETTINGS_KEYS.heatWeightInterestCount,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightInterestCount,
|
|
|
|
|
),
|
|
|
|
|
heatWeightEoiCount: pick(
|
|
|
|
|
SETTINGS_KEYS.heatWeightEoiCount,
|
|
|
|
|
asNumber,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.heatWeightEoiCount,
|
|
|
|
|
),
|
|
|
|
|
tierLadderHideLateStage: pick(
|
|
|
|
|
SETTINGS_KEYS.tierLadderHideLateStage,
|
|
|
|
|
asBool,
|
|
|
|
|
DEFAULT_RECOMMENDER_SETTINGS.tierLadderHideLateStage,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Tier mapping ──────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-15 01:18:13 +02:00
|
|
|
// L-001: modern 7-stage ranks own the canonical positions; legacy 9-stage
|
|
|
|
|
// keys map to their post-refactor equivalents so historical interests in
|
|
|
|
|
// the recommender stage-aware queries continue to rank correctly.
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
const STAGE_ORDER: Record<string, number> = {
|
2026-05-15 01:18:13 +02:00
|
|
|
// modern
|
|
|
|
|
enquiry: 1,
|
|
|
|
|
qualified: 2,
|
|
|
|
|
nurturing: 2,
|
|
|
|
|
eoi: 3,
|
|
|
|
|
reservation: 4,
|
|
|
|
|
deposit_paid: 5,
|
|
|
|
|
contract: 6,
|
|
|
|
|
// legacy aliases
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
open: 1,
|
2026-05-15 01:18:13 +02:00
|
|
|
details_sent: 1,
|
|
|
|
|
in_communication: 2,
|
|
|
|
|
eoi_sent: 3,
|
|
|
|
|
eoi_signed: 3,
|
|
|
|
|
deposit_10pct: 5,
|
|
|
|
|
contract_sent: 6,
|
|
|
|
|
contract_signed: 6,
|
|
|
|
|
completed: 6,
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Stage at which a berth is "in late stage" (Tier D when active). */
|
2026-05-15 01:18:13 +02:00
|
|
|
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_paid!; // 5
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
|
|
|
|
|
export type Tier = 'A' | 'B' | 'C' | 'D';
|
|
|
|
|
|
|
|
|
|
interface TierInputs {
|
|
|
|
|
activeInterestCount: number;
|
|
|
|
|
lostCount: number;
|
|
|
|
|
maxActiveStage: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function classifyTier(t: TierInputs): Tier {
|
|
|
|
|
if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D';
|
|
|
|
|
if (t.activeInterestCount > 0) return 'C';
|
|
|
|
|
if (t.lostCount > 0) return 'B';
|
|
|
|
|
return 'A';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Heat scoring ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface HeatInputs {
|
|
|
|
|
latestFallthroughAt: Date | null;
|
|
|
|
|
totalInterestCount: number;
|
|
|
|
|
eoiSignedCount: number;
|
|
|
|
|
/** Stage code at the time of the most recent fall-through. */
|
|
|
|
|
fallthroughMaxStage: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface HeatBreakdown {
|
|
|
|
|
recency: number;
|
|
|
|
|
furthestStage: number;
|
|
|
|
|
interestCount: number;
|
|
|
|
|
eoiCount: number;
|
|
|
|
|
total: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 0..100 heat score with per-port-tunable weights. Each component is
|
|
|
|
|
* normalized 0..1 before being multiplied by its weight; the totals sum
|
|
|
|
|
* to 100 by construction (the four weights are expected to sum to 100,
|
|
|
|
|
* but the function clamps regardless so admin-tuning errors don't
|
|
|
|
|
* produce out-of-range scores).
|
|
|
|
|
*/
|
|
|
|
|
export function computeHeat(
|
|
|
|
|
inputs: HeatInputs,
|
|
|
|
|
weights: Pick<
|
|
|
|
|
RecommenderSettings,
|
|
|
|
|
| 'heatWeightRecency'
|
|
|
|
|
| 'heatWeightFurthestStage'
|
|
|
|
|
| 'heatWeightInterestCount'
|
|
|
|
|
| 'heatWeightEoiCount'
|
|
|
|
|
>,
|
|
|
|
|
now: Date = new Date(),
|
|
|
|
|
): HeatBreakdown {
|
|
|
|
|
const weightSum =
|
|
|
|
|
weights.heatWeightRecency +
|
|
|
|
|
weights.heatWeightFurthestStage +
|
|
|
|
|
weights.heatWeightInterestCount +
|
|
|
|
|
weights.heatWeightEoiCount;
|
|
|
|
|
const norm = weightSum > 0 ? 100 / weightSum : 0;
|
|
|
|
|
|
|
|
|
|
// 1.0 if fallthrough <= 30 days, decays linearly to 0 at 365 days.
|
|
|
|
|
let recency = 0;
|
|
|
|
|
if (inputs.latestFallthroughAt) {
|
|
|
|
|
const ageDays = (now.getTime() - inputs.latestFallthroughAt.getTime()) / 86_400_000;
|
|
|
|
|
if (ageDays <= 30) recency = 1;
|
|
|
|
|
else if (ageDays >= 365) recency = 0;
|
|
|
|
|
else recency = 1 - (ageDays - 30) / (365 - 30);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 0 if max stage is open; 1 at signed/deposit/contract.
|
|
|
|
|
const furthestStage =
|
|
|
|
|
inputs.fallthroughMaxStage <= 1
|
|
|
|
|
? 0
|
|
|
|
|
: Math.min(1, (inputs.fallthroughMaxStage - 1) / (LATE_STAGE_THRESHOLD - 1));
|
|
|
|
|
|
|
|
|
|
// 0 at 0 interests, 1 at 5+ interests.
|
|
|
|
|
const interestCount = Math.min(1, inputs.totalInterestCount / 5);
|
|
|
|
|
|
|
|
|
|
// 0 at 0 EOIs, 1 at 3+ EOIs.
|
|
|
|
|
const eoiCount = Math.min(1, inputs.eoiSignedCount / 3);
|
|
|
|
|
|
|
|
|
|
const r = recency * weights.heatWeightRecency * norm;
|
|
|
|
|
const f = furthestStage * weights.heatWeightFurthestStage * norm;
|
|
|
|
|
const ic = interestCount * weights.heatWeightInterestCount * norm;
|
|
|
|
|
const ec = eoiCount * weights.heatWeightEoiCount * norm;
|
|
|
|
|
const total = Math.max(0, Math.min(100, r + f + ic + ec));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
recency: Math.round(r * 100) / 100,
|
|
|
|
|
furthestStage: Math.round(f * 100) / 100,
|
|
|
|
|
interestCount: Math.round(ic * 100) / 100,
|
|
|
|
|
eoiCount: Math.round(ec * 100) / 100,
|
|
|
|
|
total: Math.round(total * 100) / 100,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Recommend ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface RecommendBerthsArgs {
|
|
|
|
|
interestId: string;
|
|
|
|
|
portId: string;
|
|
|
|
|
/** Override the per-port topNDefault. */
|
|
|
|
|
topN?: number;
|
|
|
|
|
/** Override the per-port maxOversizePct. */
|
|
|
|
|
maxOversizePct?: number;
|
|
|
|
|
/** Show late-stage (Tier D) berths even when the per-port setting hides them. */
|
|
|
|
|
showLateStage?: boolean;
|
|
|
|
|
/** Optional rep-supplied amenity filters. */
|
|
|
|
|
amenityFilters?: {
|
|
|
|
|
minPowerCapacityKw?: number;
|
|
|
|
|
requiredVoltage?: number;
|
|
|
|
|
requiredAccess?: string;
|
|
|
|
|
requiredMooringType?: string;
|
|
|
|
|
requiredCleatCapacity?: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Recommendation {
|
|
|
|
|
berthId: string;
|
|
|
|
|
mooringNumber: string;
|
|
|
|
|
area: string | null;
|
|
|
|
|
tier: Tier;
|
|
|
|
|
fitScore: number;
|
|
|
|
|
sizeBufferPct: number | null;
|
|
|
|
|
heat: HeatBreakdown | null;
|
|
|
|
|
reasons: {
|
|
|
|
|
dimensional: string;
|
|
|
|
|
pipeline: string;
|
|
|
|
|
amenities?: string;
|
|
|
|
|
heat?: string;
|
|
|
|
|
};
|
|
|
|
|
lengthFt: number | null;
|
|
|
|
|
widthFt: number | null;
|
|
|
|
|
draftFt: number | null;
|
|
|
|
|
status: string;
|
|
|
|
|
amenities: {
|
|
|
|
|
powerCapacity: number | null;
|
|
|
|
|
voltage: number | null;
|
|
|
|
|
access: string | null;
|
|
|
|
|
mooringType: string | null;
|
|
|
|
|
cleatCapacity: string | null;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RawRow extends Record<string, unknown> {
|
|
|
|
|
berthId: string;
|
|
|
|
|
mooringNumber: string;
|
|
|
|
|
area: string | null;
|
|
|
|
|
status: string;
|
|
|
|
|
lengthFt: string | null;
|
|
|
|
|
widthFt: string | null;
|
|
|
|
|
draftFt: string | null;
|
|
|
|
|
powerCapacity: string | null;
|
|
|
|
|
voltage: string | null;
|
|
|
|
|
access: string | null;
|
|
|
|
|
mooringType: string | null;
|
|
|
|
|
cleatCapacity: string | null;
|
|
|
|
|
activeInterestCount: number;
|
|
|
|
|
lostCount: number;
|
|
|
|
|
maxActiveStage: number;
|
|
|
|
|
latestFallthroughAt: Date | null;
|
|
|
|
|
fallthroughMaxStage: number;
|
|
|
|
|
totalInterestCount: number;
|
|
|
|
|
eoiSignedCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InterestInput {
|
|
|
|
|
desiredLengthFt: number | null;
|
|
|
|
|
desiredWidthFt: number | null;
|
|
|
|
|
desiredDraftFt: number | null;
|
|
|
|
|
portId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadInterestInput(interestId: string): Promise<InterestInput | null> {
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.select({
|
|
|
|
|
desiredLengthFt: interests.desiredLengthFt,
|
|
|
|
|
desiredWidthFt: interests.desiredWidthFt,
|
|
|
|
|
desiredDraftFt: interests.desiredDraftFt,
|
|
|
|
|
portId: interests.portId,
|
|
|
|
|
})
|
|
|
|
|
.from(interests)
|
|
|
|
|
.where(eq(interests.id, interestId))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (!row) return null;
|
|
|
|
|
const toNum = (v: string | null): number | null => {
|
|
|
|
|
if (v === null) return null;
|
|
|
|
|
const n = parseFloat(v);
|
|
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
desiredLengthFt: toNum(row.desiredLengthFt),
|
|
|
|
|
desiredWidthFt: toNum(row.desiredWidthFt),
|
|
|
|
|
desiredDraftFt: toNum(row.desiredDraftFt),
|
|
|
|
|
portId: row.portId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Run the recommender. Returns ranked recommendations top-N. Multi-port
|
|
|
|
|
* isolation is enforced both in the CTE (`b.port_id = $portId`) and via
|
|
|
|
|
* the interest's own port_id - cross-port queries fail explicitly
|
|
|
|
|
* rather than leak (§14.10 critical).
|
|
|
|
|
*/
|
|
|
|
|
export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recommendation[]> {
|
|
|
|
|
const settings = await loadRecommenderSettings(args.portId);
|
|
|
|
|
const interestInput = await loadInterestInput(args.interestId);
|
|
|
|
|
if (!interestInput) return [];
|
|
|
|
|
if (interestInput.portId !== args.portId) {
|
|
|
|
|
// Defensive: caller passed a port that doesn't own this interest.
|
feat(errors): platform-wide request ids + error codes + admin inspector
End-to-end error-handling overhaul. A user hitting any failure now sees
a plain-text message + stable error code + reference id. A super admin
can paste the id into /admin/errors/<id> for the full request shape,
sanitized body, error stack, and a heuristic likely-cause hint.
REQUEST CONTEXT (AsyncLocalStorage)
- src/lib/request-context.ts mints a per-request frame carrying
requestId + portId + userId + method + path + start timestamp.
- withAuth wraps every authenticated handler in runWithRequestContext
and accepts an upstream X-Request-Id header (validated shape) or
generates a fresh UUID. The id ALWAYS leaves on the X-Request-Id
response header, including early-return 401/403/4xx paths.
- Pino logger reads from the same context via mixin — every log
line emitted during the request automatically carries the ids
with no per-call threading.
ERROR CODE REGISTRY
- src/lib/error-codes.ts defines stable DOMAIN_REASON codes with
HTTP status + plain-text user-facing message (no jargon, written
for the rep on the phone with a customer).
- New CodedError class wraps a registered code + optional
internalMessage (admin-only — never sent to client).
- Existing AppError subclasses got plain-text default rewrites so
legacy throw sites improve immediately without migration.
- High-impact services migrated to specific codes:
expenses (RECEIPT_REQUIRED, INVOICE_LINKED), interest-berths
(CROSS_PORT_LINK_REJECTED), berth-pdf (PDF_MAGIC_BYTE / PDF_EMPTY /
PDF_TOO_LARGE / VERSION_ALREADY_CURRENT), recommender
(INTEREST_PORT_MISMATCH).
ERROR ENVELOPE
- errorResponse always sets X-Request-Id header + requestId field.
- 5xx responses include a "Quote error ID …" friendly line.
- 4xx kept clean (validation, permission, not-found don't pollute
the inspector — they're already in audit log).
PERSISTENCE (error_events table, migration 0040)
- One row per 5xx, keyed on requestId, with method/path/status/error
name+message/stack head (4KB cap)/sanitized body excerpt (1KB cap;
password/token/secret/etc keys redacted)/duration/IP/UA/metadata.
- captureErrorEvent extracts Postgres SQLSTATE/severity/cause.code
so the classifier can recognize FK / unique / NOT NULL / schema-
drift violations.
- Failure to persist is logged-not-thrown.
LIKELY-CULPRIT CLASSIFIER (src/lib/error-classifier.ts)
- 4-pass heuristic (first match wins):
1. Postgres SQLSTATE → human reason (23503 FK, 23505 unique,
42703 schema drift, 53300 connection limit, …)
2. Error class name (AbortError, TimeoutError, FetchError,
ZodError)
3. Stack-path patterns (/lib/storage/, /lib/email/, documenso,
openai|claude, /queue/workers/)
4. Free-text message keywords (econnrefused, rate limit, timeout,
unauthorized|invalid api key)
- Returns { label, hint, subsystem } for the inspector badge.
CLIENT SIDE
- apiFetch throws structured ApiError with message + code + requestId
+ details + retryAfter.
- toastError() helper renders the standard 3-line toast:
plain message / Error code: X / Reference ID: Y [Copy ID].
ADMIN INSPECTOR
- /<port>/admin/errors lists captured 5xx with status badge + path +
likely-culprit badge + truncated message + reference id. Filter by
status code; auto-refresh via TanStack Query.
- /<port>/admin/errors/<requestId> deep-dive: request shape, full
error name+message+stack, sanitized body excerpt, raw metadata,
registered-code lookup (so admin can compare to what user saw),
likely-culprit hint with subsystem tag.
- /<port>/admin/errors/codes is the in-app code reference page —
every registered code grouped by domain prefix, searchable, with
HTTP status + user message inline. Linked from inspector header
so admins can flip to it while triaging.
- Permission: admin.view_audit_log. Super admins see all ports;
regular admins port-scoped.
- system-monitoring dashboard now surfaces error_events alongside
permission_denied audit + queue failed jobs (RecentError gains
source: 'request' variant).
DOCS
- docs/error-handling.md walks through coded errors, plain-text
message guidelines, client toasting, admin inspector usage,
persistence rules, classifier internals, pruning, and the
legacy → CodedError migration path.
MIGRATION SAFETY
- Audit confirmed all 41 migrations (0000-0040) apply cleanly in
journal order against an empty DB. 0040 references ports(id)
which exists from 0000. 0035/0038 don't deadlock under sequential
psql -f. Removed redundant idx_ds_sent_by from 0038 (created in
0037).
Tests: 1168/1168 vitest passing. tsc clean.
- security-error-responses tests updated for plain-text messages
+ new optional response keys (code/requestId/message).
- berth-pdf-versions tests assert stable error codes via
toMatchObject({ code }) rather than message regex.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 14:12:59 +02:00
|
|
|
throw new CodedError('RECOMMENDER_INTEREST_PORT_MISMATCH', {
|
|
|
|
|
internalMessage: `interest ${args.interestId} belongs to port ${interestInput.portId}, not ${args.portId}`,
|
|
|
|
|
});
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const oversizePct = args.maxOversizePct ?? settings.maxOversizePct;
|
|
|
|
|
const topN = args.topN ?? settings.topNDefault;
|
|
|
|
|
const showLateStage = args.showLateStage ?? !settings.tierLadderHideLateStage;
|
|
|
|
|
|
|
|
|
|
const oversizeMultiplier = 1 + oversizePct / 100;
|
|
|
|
|
|
|
|
|
|
const predicates: ReturnType<typeof sql>[] = [
|
|
|
|
|
sql`b.port_id = ${args.portId}`,
|
|
|
|
|
sql`b.status <> 'sold'`,
|
|
|
|
|
];
|
|
|
|
|
if (interestInput.desiredLengthFt !== null) {
|
|
|
|
|
predicates.push(sql`b.length_ft::numeric >= ${interestInput.desiredLengthFt}`);
|
|
|
|
|
predicates.push(
|
|
|
|
|
sql`b.length_ft::numeric <= ${interestInput.desiredLengthFt}::numeric * ${oversizeMultiplier}::numeric`,
|
|
|
|
|
);
|
2026-05-05 05:11:26 +02:00
|
|
|
} else if (interestInput.desiredWidthFt !== null) {
|
|
|
|
|
// Width-only feasibility: cap the length using a generous L/W ratio
|
|
|
|
|
// so the recommender doesn't surface a 200 ft berth for a 30 ft beam
|
|
|
|
|
// request. Plan §4.4 promised an upper bound; without this branch the
|
|
|
|
|
// null-length path skipped the cap entirely.
|
|
|
|
|
predicates.push(
|
|
|
|
|
sql`b.length_ft::numeric <= ${interestInput.desiredWidthFt}::numeric * 8::numeric * ${oversizeMultiplier}::numeric`,
|
|
|
|
|
);
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
}
|
|
|
|
|
if (interestInput.desiredWidthFt !== null) {
|
|
|
|
|
predicates.push(sql`b.width_ft::numeric >= ${interestInput.desiredWidthFt}`);
|
|
|
|
|
}
|
|
|
|
|
if (interestInput.desiredDraftFt !== null) {
|
|
|
|
|
predicates.push(sql`b.draft_ft::numeric >= ${interestInput.desiredDraftFt}`);
|
|
|
|
|
}
|
|
|
|
|
if (args.amenityFilters?.minPowerCapacityKw != null) {
|
|
|
|
|
predicates.push(sql`b.power_capacity::numeric >= ${args.amenityFilters.minPowerCapacityKw}`);
|
|
|
|
|
}
|
|
|
|
|
if (args.amenityFilters?.requiredVoltage != null) {
|
|
|
|
|
predicates.push(sql`b.voltage::numeric = ${args.amenityFilters.requiredVoltage}`);
|
|
|
|
|
}
|
|
|
|
|
if (args.amenityFilters?.requiredAccess) {
|
|
|
|
|
predicates.push(sql`b.access = ${args.amenityFilters.requiredAccess}`);
|
|
|
|
|
}
|
|
|
|
|
if (args.amenityFilters?.requiredMooringType) {
|
|
|
|
|
predicates.push(sql`b.mooring_type = ${args.amenityFilters.requiredMooringType}`);
|
|
|
|
|
}
|
|
|
|
|
if (args.amenityFilters?.requiredCleatCapacity) {
|
|
|
|
|
predicates.push(sql`b.cleat_capacity = ${args.amenityFilters.requiredCleatCapacity}`);
|
|
|
|
|
}
|
|
|
|
|
const whereClause = sql.join(predicates, sql` AND `);
|
|
|
|
|
|
|
|
|
|
const rawRows = await db.execute<RawRow>(sql`
|
|
|
|
|
WITH feasible AS (
|
|
|
|
|
SELECT b.*
|
|
|
|
|
FROM berths b
|
|
|
|
|
WHERE ${whereClause}
|
|
|
|
|
),
|
|
|
|
|
aggregates AS (
|
|
|
|
|
SELECT
|
|
|
|
|
f.id AS berth_id,
|
2026-05-05 04:20:38 +02:00
|
|
|
-- Active = is_specific_interest=true junction rows only (matches
|
|
|
|
|
-- the public-map "Under Offer" filter). An EOI-bundle-only link
|
|
|
|
|
-- (is_specific_interest=false, is_in_eoi_bundle=true) is legal
|
|
|
|
|
-- coverage, not a pitch, and shouldn't demote the berth.
|
|
|
|
|
COUNT(*) FILTER (
|
|
|
|
|
WHERE i.archived_at IS NULL
|
|
|
|
|
AND i.outcome IS NULL
|
|
|
|
|
AND ib.is_specific_interest = true
|
|
|
|
|
) AS active_interest_count,
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
COUNT(*) FILTER (
|
2026-05-05 04:07:03 +02:00
|
|
|
WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
) AS lost_count,
|
|
|
|
|
COALESCE(
|
|
|
|
|
MAX(CASE i.pipeline_stage
|
2026-05-14 14:56:58 +02:00
|
|
|
WHEN 'enquiry' THEN 1
|
|
|
|
|
WHEN 'nurturing' THEN 2
|
|
|
|
|
WHEN 'qualified' THEN 3
|
|
|
|
|
WHEN 'eoi' THEN 4
|
|
|
|
|
WHEN 'reservation' THEN 5
|
|
|
|
|
WHEN 'deposit_paid' THEN 6
|
|
|
|
|
WHEN 'contract' THEN 7
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
ELSE 0 END
|
|
|
|
|
) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL),
|
|
|
|
|
0
|
|
|
|
|
) AS max_active_stage,
|
|
|
|
|
MAX(i.outcome_at) FILTER (
|
2026-05-05 04:07:03 +02:00
|
|
|
WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
) AS latest_fallthrough_at,
|
|
|
|
|
COALESCE(
|
|
|
|
|
MAX(CASE i.pipeline_stage
|
2026-05-14 14:56:58 +02:00
|
|
|
WHEN 'enquiry' THEN 1
|
|
|
|
|
WHEN 'nurturing' THEN 2
|
|
|
|
|
WHEN 'qualified' THEN 3
|
|
|
|
|
WHEN 'eoi' THEN 4
|
|
|
|
|
WHEN 'reservation' THEN 5
|
|
|
|
|
WHEN 'deposit_paid' THEN 6
|
|
|
|
|
WHEN 'contract' THEN 7
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
ELSE 0 END
|
2026-05-05 04:07:03 +02:00
|
|
|
) FILTER (WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')),
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
0
|
|
|
|
|
) AS fallthrough_max_stage,
|
2026-05-05 04:20:38 +02:00
|
|
|
-- COUNT(ib.berth_id) (not COUNT(*)) so a berth with no junction
|
|
|
|
|
-- rows reports 0 — the LEFT JOIN otherwise produces a single
|
|
|
|
|
-- NULL-right-side row that COUNT(*) would tally as 1 and inflate
|
|
|
|
|
-- the heat interest-count component for berths with no history.
|
2026-05-05 05:11:26 +02:00
|
|
|
-- The FILTER also enforces port isolation defense-in-depth: an
|
|
|
|
|
-- orphan junction row whose interest belongs to a different port
|
|
|
|
|
-- (which the new cross-port guard now prevents but pre-existing
|
|
|
|
|
-- data may carry) shouldn't inflate this count.
|
|
|
|
|
COUNT(ib.berth_id) FILTER (WHERE i.id IS NOT NULL) AS total_interest_count,
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
COUNT(*) FILTER (WHERE i.eoi_status = 'signed') AS eoi_signed_count
|
|
|
|
|
FROM feasible f
|
|
|
|
|
LEFT JOIN interest_berths ib ON ib.berth_id = f.id
|
2026-05-05 04:07:03 +02:00
|
|
|
LEFT JOIN interests i ON i.id = ib.interest_id AND i.port_id = ${args.portId}
|
feat(recommender): SQL ranking + tier ladder + heat scoring
Plan §4.4 + §13: pure SQL recommender, no AI. Single CTE chain
(feasible -> aggregates) + JS-side tier classification, fall-through
cooldown filter, heat scoring, and fit ranking. Per-port settings via
system_settings layered over global + DEFAULT_RECOMMENDER_SETTINGS.
Tier ladder (default):
A : no interest history
B : lost-only history (still recommendable + boosted by heat)
C : active interest in early stage (open..eoi_signed)
D : active interest at deposit_10pct or beyond (hidden by default)
Heat (only for tier B):
recency weight 30 full @ <=30 days, decays to 0 @ 365 days
furthest stage weight 40 full when prior reached deposit
interest count weight 15 saturates at 5+
EOI count weight 15 saturates at 3+
Multi-port isolation enforced (§14.10 critical): the SQL filters by
port_id AND the entry-point function rejects cross-port interest
lookups with an explicit error. Fall-through policy supports
immediate_with_heat (default), cooldown, and never_auto_recommend.
15 unit tests covering tier classification, heat saturation, weight
tuning, zero-weight guard. Smoke-tested end-to-end via
scripts/dev-recommender-smoke.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:58:34 +02:00
|
|
|
GROUP BY f.id
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
f.id AS "berthId",
|
|
|
|
|
f.mooring_number AS "mooringNumber",
|
|
|
|
|
f.area,
|
|
|
|
|
f.status,
|
|
|
|
|
f.length_ft AS "lengthFt",
|
|
|
|
|
f.width_ft AS "widthFt",
|
|
|
|
|
f.draft_ft AS "draftFt",
|
|
|
|
|
f.power_capacity AS "powerCapacity",
|
|
|
|
|
f.voltage,
|
|
|
|
|
f.access,
|
|
|
|
|
f.mooring_type AS "mooringType",
|
|
|
|
|
f.cleat_capacity AS "cleatCapacity",
|
|
|
|
|
a.active_interest_count::int AS "activeInterestCount",
|
|
|
|
|
a.lost_count::int AS "lostCount",
|
|
|
|
|
a.max_active_stage::int AS "maxActiveStage",
|
|
|
|
|
a.latest_fallthrough_at AS "latestFallthroughAt",
|
|
|
|
|
a.fallthrough_max_stage::int AS "fallthroughMaxStage",
|
|
|
|
|
a.total_interest_count::int AS "totalInterestCount",
|
|
|
|
|
a.eoi_signed_count::int AS "eoiSignedCount"
|
|
|
|
|
FROM feasible f
|
|
|
|
|
JOIN aggregates a ON a.berth_id = f.id
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
const rows = (rawRows as { rows?: RawRow[] }).rows ?? (rawRows as unknown as RawRow[]);
|
|
|
|
|
|
|
|
|
|
// Apply tier classification + fall-through cooldown filter + heat scoring + fit ranking in JS.
|
|
|
|
|
const cooldownCutoff =
|
|
|
|
|
settings.fallthroughPolicy === 'cooldown'
|
|
|
|
|
? new Date(Date.now() - settings.fallthroughCooldownDays * 86_400_000)
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const recommendations: Recommendation[] = [];
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
const tier = classifyTier(r);
|
|
|
|
|
if (tier === 'D' && !showLateStage) continue;
|
|
|
|
|
|
|
|
|
|
if (tier === 'B' && settings.fallthroughPolicy === 'never_auto_recommend') continue;
|
|
|
|
|
if (
|
|
|
|
|
tier === 'B' &&
|
|
|
|
|
cooldownCutoff &&
|
|
|
|
|
r.latestFallthroughAt &&
|
|
|
|
|
new Date(r.latestFallthroughAt) > cooldownCutoff
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lengthFt = r.lengthFt === null ? null : parseFloat(r.lengthFt);
|
|
|
|
|
const widthFt = r.widthFt === null ? null : parseFloat(r.widthFt);
|
|
|
|
|
const draftFt = r.draftFt === null ? null : parseFloat(r.draftFt);
|
|
|
|
|
|
|
|
|
|
const sizeBufferPct =
|
|
|
|
|
interestInput.desiredLengthFt && lengthFt
|
|
|
|
|
? Math.round(
|
|
|
|
|
((lengthFt - interestInput.desiredLengthFt) / interestInput.desiredLengthFt) * 1000,
|
|
|
|
|
) / 10
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const heat =
|
|
|
|
|
tier === 'B'
|
|
|
|
|
? computeHeat(
|
|
|
|
|
{
|
|
|
|
|
latestFallthroughAt: r.latestFallthroughAt ? new Date(r.latestFallthroughAt) : null,
|
|
|
|
|
totalInterestCount: r.totalInterestCount,
|
|
|
|
|
eoiSignedCount: r.eoiSignedCount,
|
|
|
|
|
fallthroughMaxStage: r.fallthroughMaxStage,
|
|
|
|
|
},
|
|
|
|
|
settings,
|
|
|
|
|
)
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
// Fit score: tier weight + 1/(1+buffer) closeness + heat boost.
|
|
|
|
|
// Tier base scores: A=80, B=60+heat, C=40, D=20. Smaller buffer adds up to 20.
|
|
|
|
|
const tierBase = { A: 80, B: 60, C: 40, D: 20 }[tier];
|
|
|
|
|
const closeness =
|
|
|
|
|
sizeBufferPct === null
|
|
|
|
|
? 10
|
|
|
|
|
: Math.max(0, 20 - Math.abs(sizeBufferPct) / Math.max(1, oversizePct / 20));
|
|
|
|
|
const heatBoost = heat ? heat.total / 5 : 0;
|
|
|
|
|
const fitScore = Math.max(0, Math.min(100, tierBase + closeness + heatBoost));
|
|
|
|
|
|
|
|
|
|
recommendations.push({
|
|
|
|
|
berthId: r.berthId,
|
|
|
|
|
mooringNumber: r.mooringNumber,
|
|
|
|
|
area: r.area,
|
|
|
|
|
tier,
|
|
|
|
|
fitScore: Math.round(fitScore * 10) / 10,
|
|
|
|
|
sizeBufferPct,
|
|
|
|
|
heat,
|
|
|
|
|
reasons: {
|
|
|
|
|
dimensional:
|
|
|
|
|
sizeBufferPct === null
|
|
|
|
|
? 'No length specified - all feasible berths shown'
|
|
|
|
|
: `Within ${Math.abs(sizeBufferPct)}% of desired length`,
|
|
|
|
|
pipeline: pipelineReason(tier, r),
|
|
|
|
|
...(heat ? { heat: heatSummary(heat) } : {}),
|
|
|
|
|
...(args.amenityFilters && Object.keys(args.amenityFilters).length > 0
|
|
|
|
|
? { amenities: 'All required amenities matched' }
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
|
|
|
|
lengthFt,
|
|
|
|
|
widthFt,
|
|
|
|
|
draftFt,
|
|
|
|
|
status: r.status,
|
|
|
|
|
amenities: {
|
|
|
|
|
powerCapacity: r.powerCapacity === null ? null : parseFloat(r.powerCapacity),
|
|
|
|
|
voltage: r.voltage === null ? null : parseFloat(r.voltage),
|
|
|
|
|
access: r.access,
|
|
|
|
|
mooringType: r.mooringType,
|
|
|
|
|
cleatCapacity: r.cleatCapacity,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recommendations.sort((a, b) => {
|
|
|
|
|
const tierRank = (t: Tier): number => ({ A: 1, B: 2, C: 3, D: 4 })[t];
|
|
|
|
|
if (tierRank(a.tier) !== tierRank(b.tier)) return tierRank(a.tier) - tierRank(b.tier);
|
|
|
|
|
return b.fitScore - a.fitScore;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return recommendations.slice(0, topN);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pipelineReason(tier: Tier, r: RawRow): string {
|
|
|
|
|
switch (tier) {
|
|
|
|
|
case 'A':
|
|
|
|
|
return 'No prior interest history';
|
|
|
|
|
case 'B':
|
|
|
|
|
return `Previously fell through (${r.lostCount} time${r.lostCount === 1 ? '' : 's'})`;
|
|
|
|
|
case 'C':
|
|
|
|
|
return `${r.activeInterestCount} active interest${r.activeInterestCount === 1 ? '' : 's'} in early stage`;
|
|
|
|
|
case 'D':
|
|
|
|
|
return 'Active interest in late stage';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function heatSummary(h: HeatBreakdown): string {
|
|
|
|
|
return `Heat ${h.total.toFixed(0)}/100 (recency ${h.recency.toFixed(0)}, stage ${h.furthestStage.toFixed(0)}, count ${h.interestCount.toFixed(0)}, EOI ${h.eoiCount.toFixed(0)})`;
|
|
|
|
|
}
|