Files
pn-new-crm/src/lib/services/berth-recommender.service.ts

678 lines
24 KiB
TypeScript
Raw Normal View History

/**
* 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';
// ─── 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;
};
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 ──────────────────────────────────────────────────────────
// 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.
const STAGE_ORDER: Record<string, number> = {
// modern
enquiry: 1,
qualified: 2,
nurturing: 2,
eoi: 3,
reservation: 4,
deposit_paid: 5,
contract: 6,
// legacy aliases
open: 1,
details_sent: 1,
in_communication: 2,
eoi_sent: 3,
eoi_signed: 3,
deposit_10pct: 5,
contract_sent: 6,
contract_signed: 6,
completed: 6,
};
/** Stage at which a berth is "in late stage" (Tier D when active). */
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_paid!; // 5
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}`,
});
}
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`,
);
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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`,
);
}
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,
fix(audit-2): integration regressions + data-integrity from second-pass review Two reviewer agents did a second-pass deep audit of the 21-commit refactor. Eight findings; four fixed here (one was deferred with a schema comment, three were 🟡 nice-to-haves left for follow-up). Integration regressions (🟠 high): - Outbound webhook `interest.berth_linked` now fires from the new junction-add handler. Was emitting a socket-only event, leaving external integrations silent post-refactor. - Two new webhook events `interest.berth_unlinked` and `interest.berth_link_updated` added to WEBHOOK_EVENTS + INTERNAL_TO_WEBHOOK_MAP. PATCH and DELETE handlers now dispatch them alongside the existing socket emits — lifecycle parity restored. - BerthInterestPulse adds useRealtimeInvalidation for berth-link events. The query key was berth-scoped while the linked-berths dialog invalidates interest-scoped keys (no prefix match), so the pulse went stale. Bridges via the realtime hook now. Recommender semantic fix (🟠 medium-high): - aggregates CTE: active_interest_count now filters on `ib.is_specific_interest = true`, matching the public-map "Under Offer" derivation. EOI-bundle-only links no longer demote a berth to Tier C for other reps. Smoke test confirms previously-all-Tier-C results now correctly classify as Tier A. - Same CTE: `total_interest_count` uses COUNT(ib.berth_id) instead of COUNT(*) so a berth with no junction rows reports 0 (not 1 from the LEFT JOIN's NULL-right-side row). Prevents heat over-counting. Data integrity (🟠): - AcroForm tier rejects negative numerics in coerceFieldValue (was letting through `length_ft="-50"` which would poison the recommender feasibility filter on apply). - FilesystemBackend.resolveHmacSecret throws in production when storage_proxy_hmac_secret_encrypted is null. Dev still derives from BETTER_AUTH_SECRET for ergonomics; prod must explicitly configure. - Documented the circular FK between berths.current_pdf_version_id and berth_pdf_versions.id. Drizzle's `.references()` can't express the cycle so the schema column is plain text + a comment; the FK is authoritatively maintained by migration 0030. Tests still 1163/1163. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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,
COUNT(*) FILTER (
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
) AS lost_count,
COALESCE(
MAX(CASE i.pipeline_stage
fix(pipeline-refactor): purge stale 9-stage name references Audit of every '*_sent' / '*_signed' / 'in_communication' / 'details_sent' / 'deposit_10pct' / 'completed' literal under src/ caught four genuinely broken sites that migration 0062 collapsed away but the runtime code never followed through on: 1. alert-rules.ts: `interest.stale` matched 'details_sent' / 'in_communication' / 'eoi_sent' — none of which exist post-migration. The alert never fired. Updated to the new mid-funnel canon (enquiry / qualified / nurturing). 2. berth-recommender.service.ts: TWO copies of the same stage-rank CASE (one for active history, one for fallthrough scoring) referenced the full legacy 8-stage ladder. Every WHEN missed → MAX(...) returned 0 → tier-ladder + heat-score logic collapsed silently. Rebuilt both against the 7-stage canon mirroring getHotDeals. 3. interests.service.ts: clearInterestOutcome reopen default was the dead 'in_communication'. Switched to 'qualified' (closest analog; rep can still override via data.reopenStage). Pre-fix, any reopened deal fell through safeStage() to 'enquiry'. 4. report-generators.ts: revenue-PDF "total completed" filter intersected pipeline_stage='completed' AND outcome='won'. The stage filter is redundant today (setInterestOutcome always writes 'completed' for terminal outcomes) and is brittle to the upcoming sentinel-stage cleanup. Dropped the stage filter — outcome='won' is the canonical money-changed-hands signal. Follow-up flagged: setInterestOutcome still writes pipeline_stage = 'completed' as a sentinel, which is non-canonical under the new 7-stage type (PIPELINE_STAGES doesn't include 'completed'). Migration 0062's intent is `outcome` carries terminal state forward; pipeline_stage stays in-canon. Cleaning up requires sweeping every consumer of pipeline_stage='completed' as a terminal marker — separate commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
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 (
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
) AS latest_fallthrough_at,
COALESCE(
MAX(CASE i.pipeline_stage
fix(pipeline-refactor): purge stale 9-stage name references Audit of every '*_sent' / '*_signed' / 'in_communication' / 'details_sent' / 'deposit_10pct' / 'completed' literal under src/ caught four genuinely broken sites that migration 0062 collapsed away but the runtime code never followed through on: 1. alert-rules.ts: `interest.stale` matched 'details_sent' / 'in_communication' / 'eoi_sent' — none of which exist post-migration. The alert never fired. Updated to the new mid-funnel canon (enquiry / qualified / nurturing). 2. berth-recommender.service.ts: TWO copies of the same stage-rank CASE (one for active history, one for fallthrough scoring) referenced the full legacy 8-stage ladder. Every WHEN missed → MAX(...) returned 0 → tier-ladder + heat-score logic collapsed silently. Rebuilt both against the 7-stage canon mirroring getHotDeals. 3. interests.service.ts: clearInterestOutcome reopen default was the dead 'in_communication'. Switched to 'qualified' (closest analog; rep can still override via data.reopenStage). Pre-fix, any reopened deal fell through safeStage() to 'enquiry'. 4. report-generators.ts: revenue-PDF "total completed" filter intersected pipeline_stage='completed' AND outcome='won'. The stage filter is redundant today (setInterestOutcome always writes 'completed' for terminal outcomes) and is brittle to the upcoming sentinel-stage cleanup. Dropped the stage filter — outcome='won' is the canonical money-changed-hands signal. Follow-up flagged: setInterestOutcome still writes pipeline_stage = 'completed' as a sentinel, which is non-canonical under the new 7-stage type (PIPELINE_STAGES doesn't include 'completed'). Migration 0062's intent is `outcome` carries terminal state forward; pipeline_stage stays in-canon. Cleaning up requires sweeping every consumer of pipeline_stage='completed' as a terminal marker — separate commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
ELSE 0 END
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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')),
0
) AS fallthrough_max_stage,
fix(audit-2): integration regressions + data-integrity from second-pass review Two reviewer agents did a second-pass deep audit of the 21-commit refactor. Eight findings; four fixed here (one was deferred with a schema comment, three were 🟡 nice-to-haves left for follow-up). Integration regressions (🟠 high): - Outbound webhook `interest.berth_linked` now fires from the new junction-add handler. Was emitting a socket-only event, leaving external integrations silent post-refactor. - Two new webhook events `interest.berth_unlinked` and `interest.berth_link_updated` added to WEBHOOK_EVENTS + INTERNAL_TO_WEBHOOK_MAP. PATCH and DELETE handlers now dispatch them alongside the existing socket emits — lifecycle parity restored. - BerthInterestPulse adds useRealtimeInvalidation for berth-link events. The query key was berth-scoped while the linked-berths dialog invalidates interest-scoped keys (no prefix match), so the pulse went stale. Bridges via the realtime hook now. Recommender semantic fix (🟠 medium-high): - aggregates CTE: active_interest_count now filters on `ib.is_specific_interest = true`, matching the public-map "Under Offer" derivation. EOI-bundle-only links no longer demote a berth to Tier C for other reps. Smoke test confirms previously-all-Tier-C results now correctly classify as Tier A. - Same CTE: `total_interest_count` uses COUNT(ib.berth_id) instead of COUNT(*) so a berth with no junction rows reports 0 (not 1 from the LEFT JOIN's NULL-right-side row). Prevents heat over-counting. Data integrity (🟠): - AcroForm tier rejects negative numerics in coerceFieldValue (was letting through `length_ft="-50"` which would poison the recommender feasibility filter on apply). - FilesystemBackend.resolveHmacSecret throws in production when storage_proxy_hmac_secret_encrypted is null. Dev still derives from BETTER_AUTH_SECRET for ergonomics; prod must explicitly configure. - Documented the circular FK between berths.current_pdf_version_id and berth_pdf_versions.id. Drizzle's `.references()` can't express the cycle so the schema column is plain text + a comment; the FK is authoritatively maintained by migration 0030. Tests still 1163/1163. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
fix(audit-final): pre-merge hardening + expense receipt UI Final audit pass on feat/berth-recommender (3 parallel Opus agents) caught 5 critical and ~12 high-severity findings. All addressed in-branch; medium/low items deferred to docs/audit-final-deferred.md. Critical: - Add filesystem-backend PUT handler at /api/storage/[token] so presigned uploads stop 405-ing in filesystem mode (every browser-driven berth-PDF + brochure upload was broken). Same token-verify + replay protection as GET, plus magic-byte gate when c=application/pdf. - Forward req.signal into streamExpensePdf so an aborted 1000-receipt export no longer keeps grinding for minutes. - Strengthen Content-Disposition filename sanitization: \s matches CR/LF which would let documentName forge headers; restrict to [\w. -]+ and add filename* RFC 5987 fallback. - Lock public berths feed behind an explicit slug allowlist instead of ?portSlug= enumeration. - Reject cross-port interest_berths upserts (defense-in-depth on top of the recommender SQL port filter). High: - Recommender: width-only feasibility now caps length via L/W ratio so a 200ft berth doesn't surface for a 30ft beam request; total_interest_count filters out junction rows whose interest is in another port. - Mooring normalization follow-up migration (0034) catches un-hyphenated padded forms (A01) the original 0024 WHERE missed. - Send-out rate limit moved AFTER validation and scoped per-(port, user) so typos don't burn a slot and a multi-port rep can't be DoS'd by another tenant. - Default-brochure path now blocks an archived row from sneaking through the partial unique index. - NocoDB import --update-snapshot honoured under --dry-run so reps can refresh the seed JSON without committing DB writes. - PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when expenseIds are passed (was bypassed); flag rate-unavailable rows with an amber footer instead of silently treating them as 1:1; skip the USD->EUR chain when source already matches target. - expense-form-dialog: revokeObjectURL captures the URL in the closure instead of revoking the still-displayed one; reset upload state on close. - scan/page: handleClearReceipt resets in-flight scan/upload mutations; Save disabled while upload pending. - updateExpense re-asserts receipt-or-acknowledgement at the merged row so PATCH can't slip past the create-time refine. Plus the in-progress receipt upload UI for the expense form dialog (receipt picker + "I have no receipt" checkbox + warning banner) and a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration. Includes the canonical plan doc (referenced in CLAUDE.md), the handoff prompt, and a deferred-findings index for follow-up issues. 1163/1163 vitest passing. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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,
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
fix(audit): post-review hardening across phases 0-7 15 of 17 findings from the consolidated audit (3 reviewer agents on the previously-shipped phase commits). Remaining two are nice-to-have follow-ups deferred. Critical (data integrity / security): - Public berths API: closed-deal junction rows no longer flip a berth to "Under Offer" - filter on `interests.outcome IS NULL` so won/ lost/cancelled don't pollute public-map status. Both list + single-mooring routes. - Recommender heat: cancelled outcomes now count as fall-throughs (SQL was `LIKE 'lost%'` which silently dropped them, leaving cancelled-only berths stuck in tier A). - Filesystem presignDownload returns an absolute URL (origin from APP_URL) so emailed download links resolve from external mail clients. - Magic-byte verification on the presigned-PUT path: both per-berth PDFs and brochures stream the first 5 bytes via the storage backend and reject + delete on `%PDF-` mismatch (was only enforced when the server saw the buffer; presign-PUT was wide open). - Replay-protection TTL aligned to the token's own expiry (was a fixed 30 min, but send-out tokens live 24 h). Floor 60 s, ceiling 25 days. - Brochures unique partial index on (port_id) WHERE is_default=true + 0032 migration. Closes the read-then-write race in the create/ update transactions. Important: - Recommender SQL: defense-in-depth `i.port_id = $portId` filter on the aggregates CTE. - berth-pdf service: per-berth pg_advisory_xact_lock around the version-number SELECT + insert. Storage key is now UUID-based so concurrent uploads can't collide on blob paths. Replaces `nextVersionNumber` with the tx-bound variant. - berth-pdf apply: rejects with ConflictError when parse_results contain a mooring-mismatch warning unless the caller passes `confirmMooringMismatch: true` (force-reconfirm gate was UI-only). - Send-out body: HTML-escape brochure filename in the download-link fallback (XSS guard). - parseDecimalWithUnit rejects negative numbers. - listClients DISTINCT ON for primary contact resolution: bounds contact-row count to ~2 per client. Defensive: - verifyProxyToken rejects NaN/Infinity expiries via Number.isFinite. - Replaced sql ANY() with inArray() in interest-berths. Tests: 1145 -> 1163 passing. Deferred: bulk-send rate limit (no bulk endpoint today), markdown italic regex breaking links with asterisks (cosmetic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:07:03 +02:00
LEFT JOIN interests i ON i.id = ib.interest_id AND i.port_id = ${args.portId}
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)})`;
}