import { and, eq, count } 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 { 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; companyName: string | null; yachtName: string | null; }; port: { name: string; logoUrl: string | null; }; counts: { interests: number; documents: number; invoices: number; }; } export async function getPortalDashboard( clientId: string, portId: string, ): Promise { const [client, port, interestCount, documentCount] = 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))), ]); 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, companyName: client.companyName ?? null, yachtName: client.yachtName ?? null, }, port: { name: port.name, logoUrl: port.logoUrl ?? null, }, counts: { interests: interestCount[0]?.value ?? 0, documents: documentCount[0]?.value ?? 0, invoices: invoiceCount, }, }; } // ─── 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); }