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:
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user