feat(recommendations): read yacht dimensions from yachts table

Switch recommendations engine to read yacht dimensions (lengthFt, widthFt,
draftFt) from the yachts table via interest.yachtId instead of from the
deprecated client fields. Cross-tenant safety is maintained by scoping the
yacht lookup to the same portId. Falls back gracefully to null dimensions
when interest.yachtId is null or yacht is not found.

- Modified: src/lib/services/recommendations.ts — replaced client.yacht*Ft
  fields with yacht table lookups via interest.yachtId
- Created: tests/integration/recommendations-yacht-dims.test.ts — 4 tests
  covering happy path, null-yacht fallback, cross-tenant safety, and
  dimension-based scoring

All 594 tests passing, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 15:51:17 +02:00
parent 71d7daf1ae
commit c685c9fada
2 changed files with 288 additions and 18 deletions

View File

@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { yachts } from '@/lib/db/schema/yachts';
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
import { NotFoundError } from '@/lib/errors';
import { createAuditLog } from '@/lib/audit';
@@ -29,7 +29,10 @@ function scoreBerth(
const berthLen = parseFloat(berth.lengthFt);
if (berthLen >= yachtLengthFt) {
// Prefer berths that are not too oversized (within 20% extra is ideal)
const score = berthLen <= yachtLengthFt * 1.2 ? 100 : Math.max(50, 100 - (berthLen / yachtLengthFt - 1.2) * 100);
const score =
berthLen <= yachtLengthFt * 1.2
? 100
: Math.max(50, 100 - (berthLen / yachtLengthFt - 1.2) * 100);
reasons['length_fit'] = Math.round(score);
weights.push(score);
} else {
@@ -42,7 +45,10 @@ function scoreBerth(
if (yachtWidthFt && berth.widthFt) {
const berthWidth = parseFloat(berth.widthFt);
if (berthWidth >= yachtWidthFt) {
const score = berthWidth <= yachtWidthFt * 1.3 ? 100 : Math.max(40, 100 - (berthWidth / yachtWidthFt - 1.3) * 80);
const score =
berthWidth <= yachtWidthFt * 1.3
? 100
: Math.max(40, 100 - (berthWidth / yachtWidthFt - 1.3) * 80);
reasons['beam_fit'] = Math.round(score);
weights.push(score);
} else {
@@ -73,23 +79,27 @@ function scoreBerth(
// ─── Generate Recommendations ─────────────────────────────────────────────────
export async function generateRecommendations(
interestId: string,
portId: string,
meta: AuditMeta,
) {
export async function generateRecommendations(interestId: string, portId: string, meta: AuditMeta) {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
const client = await db.query.clients.findFirst({
where: eq(clients.id, interest.clientId),
});
// Read yacht dimensions from the yachts table via interest.yachtId (PR 9)
let yachtLengthFt: number | null = null;
let yachtWidthFt: number | null = null;
let yachtDraftFt: number | null = null;
const yachtLengthFt = client?.yachtLengthFt ? parseFloat(client.yachtLengthFt) : null;
const yachtWidthFt = client?.yachtWidthFt ? parseFloat(client.yachtWidthFt) : null;
const yachtDraftFt = client?.yachtDraftFt ? parseFloat(client.yachtDraftFt) : null;
if (interest.yachtId) {
const yacht = await db.query.yachts.findFirst({
where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)),
});
if (yacht) {
yachtLengthFt = yacht.lengthFt ? parseFloat(yacht.lengthFt) : null;
yachtWidthFt = yacht.widthFt ? parseFloat(yacht.widthFt) : null;
yachtDraftFt = yacht.draftFt ? parseFloat(yacht.draftFt) : null;
}
}
// Get all available berths for the port
const availableBerths = await db
@@ -111,10 +121,7 @@ export async function generateRecommendations(
await db
.delete(berthRecommendations)
.where(
and(
eq(berthRecommendations.interestId, interestId),
eq(berthRecommendations.source, 'ai'),
),
and(eq(berthRecommendations.interestId, interestId), eq(berthRecommendations.source, 'ai')),
);
// Insert new recommendations