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

656 lines
23 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';
// ─── 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;
};
const asBool = (v: unknown): boolean | null => (typeof v === 'boolean' ? v : 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 ──────────────────────────────────────────────────────────
const STAGE_ORDER: Record<string, number> = {
open: 1,
details_sent: 2,
in_communication: 3,
eoi_sent: 4,
eoi_signed: 5,
deposit_10pct: 6,
contract_sent: 7,
contract_signed: 8,
completed: 9,
};
/** Stage at which a berth is "in late stage" (Tier D when active). */
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_10pct!; // 6
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.
throw new Error(
`Recommender: 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
WHEN 'open' THEN 1
WHEN 'details_sent' THEN 2
WHEN 'in_communication' THEN 3
WHEN 'eoi_sent' THEN 4
WHEN 'eoi_signed' THEN 5
WHEN 'deposit_10pct' THEN 6
WHEN 'contract_sent' THEN 7
WHEN 'contract_signed' THEN 8
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
WHEN 'open' THEN 1
WHEN 'details_sent' THEN 2
WHEN 'in_communication' THEN 3
WHEN 'eoi_sent' THEN 4
WHEN 'eoi_signed' THEN 5
WHEN 'deposit_10pct' THEN 6
WHEN 'contract_sent' THEN 7
WHEN 'contract_signed' THEN 8
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)})`;
}