import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { yachts } from '@/lib/db/schema/yachts'; import { berths, berthRecommendations } from '@/lib/db/schema/berths'; import { NotFoundError } from '@/lib/errors'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; // ─── Score a single berth ───────────────────────────────────────────────────── function scoreBerth( berth: typeof berths.$inferSelect, yachtLengthFt: number | null, yachtWidthFt: number | null, yachtDraftFt: number | null, ): { score: number; reasons: Record } { const reasons: Record = {}; const weights: number[] = []; if (yachtLengthFt && berth.lengthFt) { 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); reasons['length_fit'] = Math.round(score); weights.push(score); } else { // Berth too small reasons['length_fit'] = 0; weights.push(0); } } 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); reasons['beam_fit'] = Math.round(score); weights.push(score); } else { reasons['beam_fit'] = 0; weights.push(0); } } if (yachtDraftFt && berth.draftFt) { const berthDraft = parseFloat(berth.draftFt); if (berthDraft >= yachtDraftFt) { const score = 100; reasons['draft_fit'] = score; weights.push(score); } else { reasons['draft_fit'] = 0; weights.push(0); } } if (weights.length === 0) { return { score: 50, reasons: { no_dimensions: 50 } }; } const score = Math.round(weights.reduce((a, b) => a + b, 0) / weights.length); return { score, reasons }; } // ─── Generate Recommendations ───────────────────────────────────────────────── 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'); // 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; 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 .select() .from(berths) .where(and(eq(berths.portId, portId), eq(berths.status, 'available'))); // Score each berth const scored = availableBerths.map((berth) => { const { score, reasons } = scoreBerth(berth, yachtLengthFt, yachtWidthFt, yachtDraftFt); return { berth, score, reasons }; }); // Sort by score and take top 10 scored.sort((a, b) => b.score - a.score); const top10 = scored.slice(0, 10); // Delete existing AI recommendations for this interest await db .delete(berthRecommendations) .where( and(eq(berthRecommendations.interestId, interestId), eq(berthRecommendations.source, 'ai')), ); // Insert new recommendations if (top10.length > 0) { await db.insert(berthRecommendations).values( top10.map(({ berth, score, reasons }) => ({ interestId, berthId: berth.id, matchScore: String(score), matchReasons: reasons, source: 'ai' as const, createdBy: meta.userId, })), ); } void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'berth_recommendation', entityId: interestId, metadata: { type: 'ai_generated', count: top10.length }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return listRecommendations(interestId, portId); } // ─── List Recommendations ───────────────────────────────────────────────────── export async function listRecommendations(interestId: string, portId: string) { // Verify the interest belongs to the caller's port. Without this gate, // any user with `interests:view` could pass a foreign-port interestId // and receive that tenant's recommended berths (mooring numbers, // dimensions, status - operational data they should not see). const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), }); if (!interest) throw new NotFoundError('Interest'); const rows = await db .select({ id: berthRecommendations.id, interestId: berthRecommendations.interestId, berthId: berthRecommendations.berthId, matchScore: berthRecommendations.matchScore, matchReasons: berthRecommendations.matchReasons, source: berthRecommendations.source, createdBy: berthRecommendations.createdBy, createdAt: berthRecommendations.createdAt, mooringNumber: berths.mooringNumber, area: berths.area, status: berths.status, lengthFt: berths.lengthFt, widthFt: berths.widthFt, draftFt: berths.draftFt, }) .from(berthRecommendations) .innerJoin(berths, eq(berthRecommendations.berthId, berths.id)) .where(and(eq(berthRecommendations.interestId, interestId), eq(berths.portId, portId))) .orderBy(berthRecommendations.matchScore); return rows.reverse(); // highest score first } // ─── Add Manual Recommendation ──────────────────────────────────────────────── export async function addManualRecommendation( interestId: string, portId: string, berthId: 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 berth = await db.query.berths.findFirst({ where: and(eq(berths.id, berthId), eq(berths.portId, portId)), }); if (!berth) throw new NotFoundError('Berth'); const [rec] = await db .insert(berthRecommendations) .values({ interestId, berthId, source: 'manual', createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'berth_recommendation', entityId: rec!.id, metadata: { type: 'manual', interestId, berthId }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return rec!; }