Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
217
src/lib/services/recommendations.ts
Normal file
217
src/lib/services/recommendations.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
|
||||
interface AuditMeta {
|
||||
userId: string;
|
||||
portId: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
const fit = Math.min(100, (berthLen / yachtLengthFt) * 100);
|
||||
// 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');
|
||||
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, interest.clientId),
|
||||
});
|
||||
|
||||
const yachtLengthFt = client?.yachtLengthFt ? parseFloat(client.yachtLengthFt) : null;
|
||||
const yachtWidthFt = client?.yachtWidthFt ? parseFloat(client.yachtWidthFt) : null;
|
||||
const yachtDraftFt = client?.yachtDraftFt ? parseFloat(client.yachtDraftFt) : 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) {
|
||||
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(eq(berthRecommendations.interestId, interestId))
|
||||
.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!;
|
||||
}
|
||||
Reference in New Issue
Block a user