import { and, eq, count, inArray, isNull, desc } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; 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 { createPortalToken } from '@/lib/portal/auth'; import { sendEmail } from '@/lib/email'; import { getPresignedUrl } from '@/lib/minio'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; // ─── Magic Link ──────────────────────────────────────────────────────────────── /** * Requests a magic link for portal access. * Always returns success — never reveals whether an email exists in the system. */ export async function requestMagicLink(email: string): Promise { const normalizedEmail = email.toLowerCase().trim(); // Find client contact with matching email const contact = await db.query.clientContacts.findFirst({ where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, normalizedEmail)), with: { client: true, }, }); if (!contact || !contact.client) { // Don't reveal that the email doesn't exist — silently return logger.debug({ email: normalizedEmail }, 'Portal magic link: no matching client contact'); return; } const client = contact.client; // Build the JWT const token = await createPortalToken({ clientId: client.id, portId: client.portId, email: normalizedEmail, }); const magicLinkUrl = `${env.APP_URL}/verify?token=${encodeURIComponent(token)}`; // Fetch port name for the email const port = await db.query.ports.findFirst({ where: eq(ports.id, client.portId), }); const portName = port?.name ?? 'Port Nimara'; const clientName = client.fullName; const html = `

${portName}

Client Portal

Hello, ${clientName}

You requested access to your client portal. Click the button below to sign in. This link expires in 24 hours.

Access My Portal

If you didn't request this, you can safely ignore this email. If you're having trouble with the button above, copy and paste this URL into your browser:

${magicLinkUrl}

© ${new Date().getFullYear()} ${portName}. All rights reserved.

`; await sendEmail(normalizedEmail, `Your ${portName} portal access link`, html); logger.info({ clientId: client.id, portId: client.portId }, 'Portal magic link sent'); } // ─── 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.nationality ?? 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, berthId: interests.berthId, 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); // Fetch berth details for interests that have a berth const berthIds = rows.flatMap((r) => (r.berthId ? [r.berthId] : [])); const berthMap = new Map(); if (berthIds.length > 0) { const berthRows = await db .select({ id: berths.id, mooringNumber: berths.mooringNumber, area: berths.area }) .from(berths) .where(eq(berths.portId, portId)); for (const b of berthRows) { berthMap.set(b.id, { mooringNumber: b.mooringNumber, area: b.area }); } } return rows.map((r) => ({ id: r.id, pipelineStage: r.pipelineStage, leadCategory: r.leadCategory, berthMooringNumber: r.berthId ? (berthMap.get(r.berthId)?.mooringNumber ?? null) : null, berthArea: r.berthId ? (berthMap.get(r.berthId)?.area ?? null) : 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 invoices matching any of the client's email addresses const allInvoices = await db .select() .from(invoices) .where(eq(invoices.portId, portId)) .orderBy(invoices.createdAt); const clientInvoices = allInvoices.filter( (inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()), ); 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 getPresignedUrl(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[]; }