2026-03-26 12:06:18 +01:00
|
|
|
import { and, eq } from 'drizzle-orm';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { interests } from '@/lib/db/schema/interests';
|
2026-04-24 15:51:17 +02:00
|
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
|
|
|
|
|
import { NotFoundError } from '@/lib/errors';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// ─── 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)
|
2026-04-24 15:51:17 +02:00
|
|
|
const score =
|
|
|
|
|
berthLen <= yachtLengthFt * 1.2
|
|
|
|
|
? 100
|
|
|
|
|
: Math.max(50, 100 - (berthLen / yachtLengthFt - 1.2) * 100);
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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) {
|
2026-04-24 15:51:17 +02:00
|
|
|
const score =
|
|
|
|
|
berthWidth <= yachtWidthFt * 1.3
|
|
|
|
|
? 100
|
|
|
|
|
: Math.max(40, 100 - (berthWidth / yachtWidthFt - 1.3) * 80);
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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 ─────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-24 15:51:17 +02:00
|
|
|
export async function generateRecommendations(interestId: string, portId: string, meta: AuditMeta) {
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
const interest = await db.query.interests.findFirst({
|
|
|
|
|
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
|
|
|
|
});
|
|
|
|
|
if (!interest) throw new NotFoundError('Interest');
|
|
|
|
|
|
2026-04-24 15:51:17 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// 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(
|
2026-04-24 15:51:17 +02:00
|
|
|
and(eq(berthRecommendations.interestId, interestId), eq(berthRecommendations.source, 'ai')),
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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)))
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
.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!;
|
|
|
|
|
}
|