From c685c9fadaa47e8f80f05c4d208941a42705533e Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 15:51:17 +0200 Subject: [PATCH] feat(recommendations): read yacht dimensions from yachts table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/services/recommendations.ts | 43 +-- .../recommendations-yacht-dims.test.ts | 263 ++++++++++++++++++ 2 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 tests/integration/recommendations-yacht-dims.test.ts diff --git a/src/lib/services/recommendations.ts b/src/lib/services/recommendations.ts index c5c6fca..2f398e7 100644 --- a/src/lib/services/recommendations.ts +++ b/src/lib/services/recommendations.ts @@ -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 diff --git a/tests/integration/recommendations-yacht-dims.test.ts b/tests/integration/recommendations-yacht-dims.test.ts new file mode 100644 index 0000000..20e93c9 --- /dev/null +++ b/tests/integration/recommendations-yacht-dims.test.ts @@ -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; + 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; + // 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; + // 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; + 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; + expect(bottomReasons.length_fit).toBe(0); // too small + expect(bottomReasons.beam_fit).toBe(0); // too small + expect(bottomReasons.draft_fit).toBe(0); // too small + }); +});