import { and, eq, count, inArray, isNull, desc, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { getPrimaryBerthsForInterests } from '@/lib/services/interest-berths.service'; import { documents, files } from '@/lib/db/schema/documents'; import { invoices } from '@/lib/db/schema/financial'; import { berths } from '@/lib/db/schema/berths'; import { ports } from '@/lib/db/schema/ports'; import { yachts } from '@/lib/db/schema/yachts'; import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { berthReservations } from '@/lib/db/schema/reservations'; import { presignDownloadUrl } from '@/lib/storage'; import { getCountryName } from '@/lib/i18n/countries'; // ─── Dashboard ──────────────────────────────────────────────────────────────── export interface PortalDashboard { client: { id: string; fullName: string; nationality: string | null; }; port: { name: string; logoUrl: string | null; }; counts: { interests: number; documents: number; invoices: number; yachts: number; memberships: number; activeReservations: number; }; } export async function getPortalDashboard( clientId: string, portId: string, ): Promise { const [client, port, interestCount, documentCount, yachtList, membershipList, reservationList] = await Promise.all([ db.query.clients.findFirst({ where: and(eq(clients.id, clientId), eq(clients.portId, portId)), with: { contacts: true }, }), db.query.ports.findFirst({ where: eq(ports.id, portId), }), db .select({ value: count() }) .from(interests) .where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))), db .select({ value: count() }) .from(documents) .where(and(eq(documents.clientId, clientId), eq(documents.portId, portId))), getPortalUserYachts(clientId, portId), getPortalUserMemberships(clientId, portId), getPortalUserReservations(clientId, portId), ]); if (!client || !port) return null; // Count invoices matched by client's billing email addresses const emailContacts = (client.contacts ?? []) .filter((c) => c.channel === 'email') .map((c) => c.value.toLowerCase()); let invoiceCount = 0; if (emailContacts.length > 0) { const allPortInvoices = await db .select({ billingEmail: invoices.billingEmail }) .from(invoices) .where(eq(invoices.portId, portId)); invoiceCount = allPortInvoices.filter( (inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()), ).length; } return { client: { id: client.id, fullName: client.fullName, nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null, }, port: { name: port.name, logoUrl: port.logoUrl ?? null, }, counts: { interests: interestCount[0]?.value ?? 0, documents: documentCount[0]?.value ?? 0, invoices: invoiceCount, yachts: yachtList.length, memberships: membershipList.length, activeReservations: reservationList.length, }, }; } // ─── Interests ──────────────────────────────────────────────────────────────── export interface PortalInterest { id: string; pipelineStage: string; leadCategory: string | null; berthMooringNumber: string | null; berthArea: string | null; eoiStatus: string | null; contractStatus: string | null; dateFirstContact: Date | null; createdAt: Date; } export async function getClientInterests( clientId: string, portId: string, ): Promise { const rows = await db .select({ id: interests.id, pipelineStage: interests.pipelineStage, leadCategory: interests.leadCategory, eoiStatus: interests.eoiStatus, contractStatus: interests.contractStatus, dateFirstContact: interests.dateFirstContact, createdAt: interests.createdAt, }) .from(interests) .where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))) .orderBy(interests.createdAt); // Resolve each interest's primary berth via the junction (plan §3.4) - // single round-trip for the whole list. const primaryBerthMap = await getPrimaryBerthsForInterests(rows.map((r) => r.id)); const primaryBerthIds = Array.from( new Set(Array.from(primaryBerthMap.values(), (b) => b.berthId)), ); const berthMap = new Map(); if (primaryBerthIds.length > 0) { const berthRows = await db .select({ id: berths.id, mooringNumber: berths.mooringNumber, area: berths.area }) .from(berths) .where(and(eq(berths.portId, portId), inArray(berths.id, primaryBerthIds))); for (const b of berthRows) { berthMap.set(b.id, { mooringNumber: b.mooringNumber, area: b.area }); } } return rows.map((r) => { const primary = primaryBerthMap.get(r.id); const berthMeta = primary ? (berthMap.get(primary.berthId) ?? null) : null; return { id: r.id, pipelineStage: r.pipelineStage, leadCategory: r.leadCategory, berthMooringNumber: berthMeta?.mooringNumber ?? null, berthArea: berthMeta?.area ?? null, eoiStatus: r.eoiStatus, contractStatus: r.contractStatus, dateFirstContact: r.dateFirstContact, createdAt: r.createdAt, }; }); } // ─── Documents ──────────────────────────────────────────────────────────────── export interface PortalDocument { id: string; documentType: string; title: string; status: string; isManualUpload: boolean; hasSignedFile: boolean; signers: Array<{ signerName: string; signerEmail: string; signerRole: string; status: string; }>; createdAt: Date; } export async function getClientDocuments( clientId: string, portId: string, ): Promise { const rows = await db.query.documents.findMany({ where: and(eq(documents.clientId, clientId), eq(documents.portId, portId)), with: { signers: true, }, orderBy: (docs, { desc }) => [desc(docs.createdAt)], }); return rows.map((doc) => ({ id: doc.id, documentType: doc.documentType, title: doc.title, status: doc.status, isManualUpload: doc.isManualUpload, hasSignedFile: doc.signedFileId != null, signers: (doc.signers ?? []).map((s) => ({ signerName: s.signerName, signerEmail: s.signerEmail, signerRole: s.signerRole, status: s.status, })), createdAt: doc.createdAt, })); } // ─── Invoices ───────────────────────────────────────────────────────────────── export interface PortalInvoice { id: string; invoiceNumber: string; status: string; currency: string; total: string; dueDate: string; paymentStatus: string | null; paymentDate: string | null; createdAt: Date; } export async function getClientInvoices( clientId: string, portId: string, ): Promise { // Look up the client to get billing email for invoice matching const client = await db.query.clients.findFirst({ where: and(eq(clients.id, clientId), eq(clients.portId, portId)), with: { contacts: true, }, }); if (!client) return []; // Get client's email addresses to match against billingEmail const emailContacts = (client.contacts ?? []) .filter((c) => c.channel === 'email') .map((c) => c.value.toLowerCase()); if (emailContacts.length === 0) return []; // Fetch only the invoices matching any of the client's email addresses. // Without the inArray push-down here every portal invoice page-load // full-scanned the invoices table and filtered in JS — by 12mo it would // have been the worst portal endpoint in the platform. Defensive limit // 100 caps the upper bound for clients with abnormally many invoices. const clientInvoices = await db .select() .from(invoices) .where( and( eq(invoices.portId, portId), inArray(sql`lower(${invoices.billingEmail})`, emailContacts), ), ) .orderBy(invoices.createdAt) .limit(100); return clientInvoices.map((inv) => ({ id: inv.id, invoiceNumber: inv.invoiceNumber, status: inv.status, currency: inv.currency, total: inv.total, dueDate: inv.dueDate, paymentStatus: inv.paymentStatus ?? null, paymentDate: inv.paymentDate ?? null, createdAt: inv.createdAt, })); } // ─── Document Download ──────────────────────────────────────────────────────── export async function getDocumentDownloadUrl( clientId: string, documentId: string, portId: string, ): Promise { const doc = await db.query.documents.findFirst({ where: and( eq(documents.id, documentId), eq(documents.clientId, clientId), eq(documents.portId, portId), ), }); if (!doc) return null; // Prefer signed file, fall back to original file const fileId = doc.signedFileId ?? doc.fileId; if (!fileId) return null; const file = await db.query.files.findFirst({ where: eq(files.id, fileId), }); if (!file) return null; return presignDownloadUrl(file.storagePath); } // ─── Yachts (direct + via company) ──────────────────────────────────────────── export interface PortalYacht { id: string; name: string; hullNumber: string | null; registration: string | null; flag: string | null; yearBuilt: number | null; lengthFt: string | null; widthFt: string | null; status: string; ownerContext: 'direct' | 'company'; ownerCompanyId: string | null; ownerCompanyName: string | null; } function toPortalYacht(y: typeof yachts.$inferSelect): PortalYacht { return { id: y.id, name: y.name, hullNumber: y.hullNumber, registration: y.registration, flag: y.flag, yearBuilt: y.yearBuilt, lengthFt: y.lengthFt, widthFt: y.widthFt, status: y.status, ownerContext: 'direct', ownerCompanyId: null, ownerCompanyName: null, }; } export async function getPortalUserYachts( clientId: string, portId: string, ): Promise { // 1. Direct yachts const directYachts = await db.query.yachts.findMany({ where: and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'client'), eq(yachts.currentOwnerId, clientId), isNull(yachts.archivedAt), ), }); // 2. Active company memberships const memberships = await db .select({ companyId: companyMemberships.companyId }) .from(companyMemberships) .innerJoin(companies, eq(companyMemberships.companyId, companies.id)) .where( and( eq(companyMemberships.clientId, clientId), eq(companies.portId, portId), isNull(companyMemberships.endDate), ), ); const companyIds = memberships.map((m) => m.companyId); // De-dup by yacht.id const seen = new Set(); const result: PortalYacht[] = []; for (const y of directYachts) { if (seen.has(y.id)) continue; seen.add(y.id); result.push(toPortalYacht(y)); } if (companyIds.length === 0) { return result; } // 3. Company-owned yachts const companyYachts = await db .select({ yacht: yachts, company: { id: companies.id, name: companies.name }, }) .from(yachts) .innerJoin(companies, eq(yachts.currentOwnerId, companies.id)) .where( and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'company'), inArray(yachts.currentOwnerId, companyIds), isNull(yachts.archivedAt), ), ); for (const row of companyYachts) { if (seen.has(row.yacht.id)) continue; seen.add(row.yacht.id); result.push({ id: row.yacht.id, name: row.yacht.name, hullNumber: row.yacht.hullNumber, registration: row.yacht.registration, flag: row.yacht.flag, yearBuilt: row.yacht.yearBuilt, lengthFt: row.yacht.lengthFt, widthFt: row.yacht.widthFt, status: row.yacht.status, ownerContext: 'company', ownerCompanyId: row.company.id, ownerCompanyName: row.company.name, }); } return result; } // ─── Memberships ────────────────────────────────────────────────────────────── export interface PortalMembership { membershipId: string; role: string; isPrimary: boolean; startDate: Date; company: { id: string; name: string; legalName: string | null; status: string; }; } export async function getPortalUserMemberships( clientId: string, portId: string, ): Promise { const rows = await db .select({ membershipId: companyMemberships.id, role: companyMemberships.role, isPrimary: companyMemberships.isPrimary, startDate: companyMemberships.startDate, company: { id: companies.id, name: companies.name, legalName: companies.legalName, status: companies.status, }, }) .from(companyMemberships) .innerJoin(companies, eq(companyMemberships.companyId, companies.id)) .where( and( eq(companyMemberships.clientId, clientId), eq(companies.portId, portId), isNull(companyMemberships.endDate), ), ); return rows; } // ─── Reservations ───────────────────────────────────────────────────────────── export interface PortalReservation { id: string; berthId: string; berthMooringNumber: string | null; yachtId: string; yachtName: string | null; status: 'pending' | 'active' | 'ended' | 'cancelled'; startDate: Date; endDate: Date | null; tenureType: string; } export async function getPortalUserReservations( clientId: string, portId: string, ): Promise { const rows = await db .select({ id: berthReservations.id, berthId: berthReservations.berthId, berthMooringNumber: berths.mooringNumber, yachtId: berthReservations.yachtId, yachtName: yachts.name, status: berthReservations.status, startDate: berthReservations.startDate, endDate: berthReservations.endDate, tenureType: berthReservations.tenureType, }) .from(berthReservations) .innerJoin(berths, eq(berthReservations.berthId, berths.id)) .innerJoin(yachts, eq(berthReservations.yachtId, yachts.id)) .where( and( eq(berthReservations.clientId, clientId), eq(berthReservations.portId, portId), inArray(berthReservations.status, ['pending', 'active']), ), ) .orderBy(desc(berthReservations.createdAt)); return rows as PortalReservation[]; }