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 { 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';
|
2026-03-26 12:06:18 +01:00
|
|
|
import { documents, files } from '@/lib/db/schema/documents';
|
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 { 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<void> {
|
|
|
|
|
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 = `
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
</head>
|
|
|
|
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 40px 0; margin: 0;">
|
|
|
|
|
<div style="max-width: 480px; margin: 0 auto; background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
|
|
|
|
|
<div style="background: #1e2844; padding: 32px 40px; text-align: center;">
|
|
|
|
|
<h1 style="color: #ffffff; margin: 0; font-size: 22px; font-weight: 600;">${portName}</h1>
|
|
|
|
|
<p style="color: #9ca3af; margin: 6px 0 0; font-size: 14px;">Client Portal</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="padding: 40px;">
|
|
|
|
|
<p style="color: #374151; font-size: 16px; margin: 0 0 8px;">Hello, ${clientName}</p>
|
|
|
|
|
<p style="color: #6b7280; font-size: 15px; margin: 0 0 32px; line-height: 1.6;">
|
|
|
|
|
You requested access to your client portal. Click the button below to sign in. This link expires in 24 hours.
|
|
|
|
|
</p>
|
|
|
|
|
<div style="text-align: center; margin: 0 0 32px;">
|
|
|
|
|
<a href="${magicLinkUrl}" style="display: inline-block; background: #1e2844; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-size: 15px; font-weight: 500;">
|
|
|
|
|
Access My Portal
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
<p style="color: #9ca3af; font-size: 13px; margin: 0; line-height: 1.6;">
|
|
|
|
|
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:
|
|
|
|
|
<br><br>
|
|
|
|
|
<span style="color: #6b7280; word-break: break-all;">${magicLinkUrl}</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="background: #f9fafb; padding: 20px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
|
|
|
|
|
<p style="color: #9ca3af; font-size: 12px; margin: 0;">© ${new Date().getFullYear()} ${portName}. All rights reserved.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
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<PortalDashboard | null> {
|
|
|
|
|
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<PortalInterest[]> {
|
|
|
|
|
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<string, { mooringNumber: string; area: string | null }>();
|
|
|
|
|
|
|
|
|
|
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<PortalDocument[]> {
|
|
|
|
|
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<PortalInvoice[]> {
|
|
|
|
|
// 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<string | null> {
|
|
|
|
|
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);
|
|
|
|
|
}
|