Files
pn-new-crm/src/lib/services/recommendations.ts

226 lines
7.4 KiB
TypeScript
Raw Normal View History

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<string, number> } {
const reasons: Record<string, number> = {};
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 ─────────────────────────────────────────────────────
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
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))
sec: lock down 5 cross-tenant FK gaps from fifth-pass review 1. HIGH — reminders.create/updateReminder accepted clientId/interestId/ berthId from the body and persisted them with no port check; getReminder then hydrated the row via Drizzle relations (no port filter on the join), so a port-A user with reminders:create could exfiltrate any port-B client/interest/berth row by guessing its UUID. New assertReminderFksInPort gates create + update. 2. HIGH — listRecommendations(interestId, _portId) discarded portId entirely; the route GET /api/v1/interests/[id]/recommendations forwarded the URL id straight through. A port-A user with interests:view could read any other tenant's recommended berths (mooring numbers, dimensions, status). Service now verifies the interest belongs to portId and joins berths filtered by port. 3. HIGH — Berth waiting list. The PATCH route did not pre-check that the berth belonged to ctx.portId — a port-A user with manage_waiting_list could reorder a port-B berth's queue. Separately, updateWaitingList accepted arbitrary entries[].clientId and inserted them without verifying tenancy, polluting the table with foreign-port FKs. Both gaps closed. 4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths) accepted any tagId and inserted into the join table. The tags table is per-port but the join only carries a single-column FK. The downstream getById join `tags ON join.tag_id = tags.id` has no port filter, so a foreign tag's name + color render in the requesting port. Helper now batch-validates tagIds belong to portId before insert. 5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission gate (any role, including viewer, could write) and didn't validate that the URL entityId pointed at a port-scoped entity of the field definition's entityType. Route now uses withPermission('clients','view'/'edit',…); service validates the entityId per resolved entityType (client/interest/berth/yacht/company) against portId. Test mocks updated to cover the new entity-port-scope check. 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00
.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!;
}