feat(portal): surface yachts, memberships, reservations for portal users

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 14:43:12 +02:00
parent b75834ab7e
commit a14dc8143c
6 changed files with 746 additions and 53 deletions

View File

@@ -1,4 +1,4 @@
import { and, eq, count } from 'drizzle-orm';
import { and, eq, count, inArray, isNull, desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
@@ -7,6 +7,9 @@ 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';
@@ -24,10 +27,7 @@ export async function requestMagicLink(email: string): Promise<void> {
// Find client contact with matching email
const contact = await db.query.clientContacts.findFirst({
where: and(
eq(clientContacts.channel, 'email'),
eq(clientContacts.value, normalizedEmail),
),
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, normalizedEmail)),
with: {
client: true,
},
@@ -95,11 +95,7 @@ export async function requestMagicLink(email: string): Promise<void> {
</html>
`;
await sendEmail(
normalizedEmail,
`Your ${portName} portal access link`,
html,
);
await sendEmail(normalizedEmail, `Your ${portName} portal access link`, html);
logger.info({ clientId: client.id, portId: client.portId }, 'Portal magic link sent');
}
@@ -110,8 +106,7 @@ export interface PortalDashboard {
client: {
id: string;
fullName: string;
companyName: string | null;
yachtName: string | null;
nationality: string | null;
};
port: {
name: string;
@@ -121,6 +116,9 @@ export interface PortalDashboard {
interests: number;
documents: number;
invoices: number;
yachts: number;
memberships: number;
activeReservations: number;
};
}
@@ -128,23 +126,27 @@ 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))),
]);
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;
@@ -168,8 +170,7 @@ export async function getPortalDashboard(
client: {
id: client.id,
fullName: client.fullName,
companyName: client.companyName ?? null,
yachtName: client.yachtName ?? null,
nationality: client.nationality ?? null,
},
port: {
name: port.name,
@@ -179,6 +180,9 @@ export async function getPortalDashboard(
interests: interestCount[0]?.value ?? 0,
documents: documentCount[0]?.value ?? 0,
invoices: invoiceCount,
yachts: yachtList.length,
memberships: membershipList.length,
activeReservations: reservationList.length,
},
};
}
@@ -213,12 +217,7 @@ export async function getClientInterests(
createdAt: interests.createdAt,
})
.from(interests)
.where(
and(
eq(interests.clientId, clientId),
eq(interests.portId, portId),
),
)
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId)))
.orderBy(interests.createdAt);
// Fetch berth details for interests that have a berth
@@ -271,10 +270,7 @@ export async function getClientDocuments(
portId: string,
): Promise<PortalDocument[]> {
const rows = await db.query.documents.findMany({
where: and(
eq(documents.clientId, clientId),
eq(documents.portId, portId),
),
where: and(eq(documents.clientId, clientId), eq(documents.portId, portId)),
with: {
signers: true,
},
@@ -341,8 +337,7 @@ export async function getClientInvoices(
.orderBy(invoices.createdAt);
const clientInvoices = allInvoices.filter(
(inv) =>
inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
(inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
);
return clientInvoices.map((inv) => ({
@@ -387,3 +382,207 @@ export async function getDocumentDownloadUrl(
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<PortalYacht[]> {
// 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<string>();
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<PortalMembership[]> {
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<PortalReservation[]> {
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[];
}