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

View File

@@ -0,0 +1,263 @@
/**
* recommendations.service yacht dimensions source integration tests.
*
* Covers:
* - generateRecommendations reads yacht dimensions from yachts table (not client) when interest.yachtId is set
* - Falls back to null dimensions when interest has no yacht linked
* - Correctly guards against cross-tenant access (yacht.portId must match)
* - Recommendations are scored based on yacht dimensions from yachts table
*
* Uses dynamic imports so env is loaded before service modules touch `db`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
describe('recommendations.service — yacht dimensions source', () => {
let generateRecommendations: typeof import('@/lib/services/recommendations').generateRecommendations;
let makePort: typeof import('../helpers/factories').makePort;
let makeClient: typeof import('../helpers/factories').makeClient;
let makeYacht: typeof import('../helpers/factories').makeYacht;
let makeBerth: typeof import('../helpers/factories').makeBerth;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
beforeAll(async () => {
const svc = await import('@/lib/services/recommendations');
generateRecommendations = svc.generateRecommendations;
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeClient = factories.makeClient;
makeYacht = factories.makeYacht;
makeBerth = factories.makeBerth;
makeAuditMeta = factories.makeAuditMeta;
});
it('reads yacht dimensions from yachts table (not client) when interest.yachtId is set', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
overrides: {
lengthFt: '45',
widthFt: '14',
draftFt: '7',
},
});
// Create a berth that matches the yacht dimensions
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const berth = await makeBerth({
portId: port.id,
overrides: {
lengthFt: '50',
widthFt: '15',
draftFt: '8',
},
});
// Insert interest with yachtId
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'open',
})
.returning();
const recommendations = await generateRecommendations(
interest!.id,
port.id,
makeAuditMeta({ portId: port.id }),
);
// Should have at least one recommendation with scoring based on yacht dims
expect(recommendations.length).toBeGreaterThan(0);
const rec = recommendations[0];
expect(rec).toBeDefined();
// length_fit, beam_fit, draft_fit should be in reasons
const reasons = rec!.matchReasons as Record<string, number>;
expect(reasons).toHaveProperty('length_fit');
expect(reasons).toHaveProperty('beam_fit');
expect(reasons).toHaveProperty('draft_fit');
});
it('falls back to null dimensions when interest has no yacht linked', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Create a berth
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const berth = await makeBerth({
portId: port.id,
overrides: {
lengthFt: '50',
widthFt: '15',
draftFt: '8',
},
});
// Insert interest without yachtId
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
yachtId: null,
pipelineStage: 'open',
})
.returning();
const recommendations = await generateRecommendations(
interest!.id,
port.id,
makeAuditMeta({ portId: port.id }),
);
// Should still have recommendations, but with fallback scoring (no_dimensions reason)
expect(recommendations.length).toBeGreaterThan(0);
const rec = recommendations[0];
const reasons = rec!.matchReasons as Record<string, number>;
// When no yacht dimensions, the score should use the fallback (no_dimensions)
expect(reasons).toHaveProperty('no_dimensions');
});
it('falls back to null dimensions when yacht is cross-tenant (safety)', async () => {
const port1 = await makePort();
const port2 = await makePort();
const client = await makeClient({ portId: port1.id });
// Create yacht in port2
const yachtInPort2 = await makeYacht({
portId: port2.id,
ownerType: 'client',
ownerId: client.id,
overrides: {
lengthFt: '45',
widthFt: '14',
draftFt: '7',
},
});
// Create berth in port1
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const berth = await makeBerth({
portId: port1.id,
overrides: {
lengthFt: '50',
widthFt: '15',
draftFt: '8',
},
});
// Insert interest in port1 but with yachtId pointing to port2's yacht
// This simulates a cross-tenant safety issue
const [interest] = await db
.insert(interests)
.values({
portId: port1.id,
clientId: client.id,
yachtId: yachtInPort2.id,
pipelineStage: 'open',
})
.returning();
const recommendations = await generateRecommendations(
interest!.id,
port1.id, // Asking for port1 recommendations
makeAuditMeta({ portId: port1.id }),
);
// The yacht lookup should fail due to portId mismatch, so fall back to null dims
expect(recommendations.length).toBeGreaterThan(0);
const rec = recommendations[0];
const reasons = rec!.matchReasons as Record<string, number>;
// Should use no_dimensions fallback because yacht from another port is not found
expect(reasons).toHaveProperty('no_dimensions');
});
it('correctly scores berths based on yacht dimension matches', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// Create a 40ft yacht
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
overrides: {
lengthFt: '40',
widthFt: '12',
draftFt: '6',
},
});
// Create two berths: one that fits perfectly, one that's too small
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const perfectFitBerth = await makeBerth({
portId: port.id,
overrides: {
mooringNumber: 'PERFECT',
lengthFt: '42', // within 20% of 40
widthFt: '13', // within 30% of 12
draftFt: '7', // sufficient for 6
},
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const tooSmallBerth = await makeBerth({
portId: port.id,
overrides: {
mooringNumber: 'SMALL',
lengthFt: '35', // smaller than yacht
widthFt: '10', // smaller than yacht
draftFt: '5', // smaller than yacht
},
});
// Insert interest with yacht
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'open',
})
.returning();
const recommendations = await generateRecommendations(
interest!.id,
port.id,
makeAuditMeta({ portId: port.id }),
);
// Should have 2 recommendations
expect(recommendations.length).toBe(2);
// The first (highest score) should be the perfect-fit berth
const topRec = recommendations[0];
expect(topRec!.mooringNumber).toBe('PERFECT');
const topReasons = topRec!.matchReasons as Record<string, number>;
expect(topReasons.length_fit).toBe(100); // perfect fit
expect(topReasons.beam_fit).toBe(100); // perfect fit
expect(topReasons.draft_fit).toBe(100); // perfect fit
// The second should be the too-small berth with 0 scores
const bottomRec = recommendations[1];
expect(bottomRec!.mooringNumber).toBe('SMALL');
const bottomReasons = bottomRec!.matchReasons as Record<string, number>;
expect(bottomReasons.length_fit).toBe(0); // too small
expect(bottomReasons.beam_fit).toBe(0); // too small
expect(bottomReasons.draft_fit).toBe(0); // too small
});
});