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,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { Anchor, FileText, Receipt } from 'lucide-react';
|
import { Anchor, FileText, Receipt, Sailboat, Building2, CalendarCheck } from 'lucide-react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getPortalSession } from '@/lib/portal/auth';
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
@@ -21,15 +21,12 @@ export default async function PortalDashboardPage() {
|
|||||||
<h1 className="text-2xl font-semibold text-gray-900">
|
<h1 className="text-2xl font-semibold text-gray-900">
|
||||||
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
||||||
</h1>
|
</h1>
|
||||||
{dashboard.client.companyName && (
|
{dashboard.client.nationality && (
|
||||||
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
|
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
|
||||||
)}
|
|
||||||
{dashboard.client.yachtName && (
|
|
||||||
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<PortalCard
|
<PortalCard
|
||||||
title="Berth Interests"
|
title="Berth Interests"
|
||||||
value={dashboard.counts.interests}
|
value={dashboard.counts.interests}
|
||||||
@@ -51,13 +48,33 @@ export default async function PortalDashboardPage() {
|
|||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
href="/portal/invoices"
|
href="/portal/invoices"
|
||||||
/>
|
/>
|
||||||
|
<PortalCard
|
||||||
|
title="My Yachts"
|
||||||
|
value={dashboard.counts.yachts}
|
||||||
|
description="Vessels you own directly or through a company"
|
||||||
|
icon={Sailboat}
|
||||||
|
href="/portal/my-yachts"
|
||||||
|
/>
|
||||||
|
<PortalCard
|
||||||
|
title="My Memberships"
|
||||||
|
value={dashboard.counts.memberships}
|
||||||
|
description="Companies where you hold an active role"
|
||||||
|
icon={Building2}
|
||||||
|
/>
|
||||||
|
<PortalCard
|
||||||
|
title="My Active Reservations"
|
||||||
|
value={dashboard.counts.activeReservations}
|
||||||
|
description="Current and pending berth reservations"
|
||||||
|
icon={CalendarCheck}
|
||||||
|
href="/portal/my-reservations"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg border p-6">
|
<div className="bg-white rounded-lg border p-6">
|
||||||
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
|
Contact the {dashboard.port.name} team directly. This portal provides a read-only view of
|
||||||
of your account. All changes must be made through your port contact.
|
your account. All changes must be made through your port contact.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
src/app/(portal)/portal/my-reservations/page.tsx
Normal file
83
src/app/(portal)/portal/my-reservations/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { CalendarCheck } from 'lucide-react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
|
import { getPortalUserReservations } from '@/lib/services/portal.service';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'My Reservations' };
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
pending: 'secondary',
|
||||||
|
active: 'default',
|
||||||
|
ended: 'outline',
|
||||||
|
cancelled: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TENURE_LABELS: Record<string, string> = {
|
||||||
|
permanent: 'Permanent',
|
||||||
|
fixed_term: 'Fixed term',
|
||||||
|
seasonal: 'Seasonal',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(d: Date | string): string {
|
||||||
|
return new Date(d).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PortalMyReservationsPage() {
|
||||||
|
const session = await getPortalSession();
|
||||||
|
if (!session) redirect('/portal/login');
|
||||||
|
|
||||||
|
const reservations = await getPortalUserReservations(session.clientId, session.portId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">My Reservations</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Your current and pending berth reservations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reservations.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border p-12 text-center">
|
||||||
|
<CalendarCheck className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 font-medium">No active reservations</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Contact your port representative to discuss reservations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{reservations.map((r) => (
|
||||||
|
<div key={r.id} className="bg-white rounded-lg border p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
|
||||||
|
{r.berthMooringNumber && (
|
||||||
|
<span className="text-sm text-gray-400">— Berth {r.berthMooringNumber}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{TENURE_LABELS[r.tenureType] ?? r.tenureType}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2 text-xs text-gray-400">
|
||||||
|
<span>
|
||||||
|
From {formatDate(r.startDate)}
|
||||||
|
{r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={STATUS_COLORS[r.status] ?? 'default'}>{r.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/app/(portal)/portal/my-yachts/page.tsx
Normal file
77
src/app/(portal)/portal/my-yachts/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { Sailboat } from 'lucide-react';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { getPortalSession } from '@/lib/portal/auth';
|
||||||
|
import { getPortalUserYachts } from '@/lib/services/portal.service';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'My Yachts' };
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
|
active: 'default',
|
||||||
|
retired: 'secondary',
|
||||||
|
sold_away: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function PortalMyYachtsPage() {
|
||||||
|
const session = await getPortalSession();
|
||||||
|
if (!session) redirect('/portal/login');
|
||||||
|
|
||||||
|
const yachts = await getPortalUserYachts(session.clientId, session.portId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">My Yachts</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Vessels you own directly or through a company</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{yachts.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border p-12 text-center">
|
||||||
|
<Sailboat className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 font-medium">No yachts on file</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Yachts owned by you or a company you are a member of will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{yachts.map((y) => (
|
||||||
|
<div key={y.id} className="bg-white rounded-lg border p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Sailboat className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{y.name}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'}
|
||||||
|
{y.flag ? ` · ${y.flag}` : ''}
|
||||||
|
{y.yearBuilt ? ` · ${y.yearBuilt}` : ''}
|
||||||
|
</p>
|
||||||
|
{y.ownerContext === 'company' && y.ownerCompanyName && (
|
||||||
|
<p className="text-xs text-[#1e2844] mt-2">Owned by {y.ownerCompanyName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant={STATUS_COLORS[y.status] ?? 'default'}>
|
||||||
|
{y.status.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(y.lengthFt || y.widthFt || y.registration) && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||||
|
{y.registration && <span>Reg: {y.registration}</span>}
|
||||||
|
{y.lengthFt && <span>Length: {y.lengthFt}ft</span>}
|
||||||
|
{y.widthFt && <span>Beam: {y.widthFt}ft</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { LayoutDashboard, Anchor, FileText, Receipt } from 'lucide-react';
|
import { LayoutDashboard, Anchor, FileText, Receipt, Sailboat, CalendarCheck } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
|
||||||
{ label: 'Interests', href: '/portal/interests', icon: Anchor },
|
{ label: 'Interests', href: '/portal/interests', icon: Anchor },
|
||||||
|
{ label: 'My Yachts', href: '/portal/my-yachts', icon: Sailboat },
|
||||||
|
{ label: 'Reservations', href: '/portal/my-reservations', icon: CalendarCheck },
|
||||||
{ label: 'Documents', href: '/portal/documents', icon: FileText },
|
{ label: 'Documents', href: '/portal/documents', icon: FileText },
|
||||||
{ label: 'Invoices', href: '/portal/invoices', icon: Receipt },
|
{ label: 'Invoices', href: '/portal/invoices', icon: Receipt },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
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 { invoices } from '@/lib/db/schema/financial';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
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 { createPortalToken } from '@/lib/portal/auth';
|
||||||
import { sendEmail } from '@/lib/email';
|
import { sendEmail } from '@/lib/email';
|
||||||
import { getPresignedUrl } from '@/lib/minio';
|
import { getPresignedUrl } from '@/lib/minio';
|
||||||
@@ -24,10 +27,7 @@ export async function requestMagicLink(email: string): Promise<void> {
|
|||||||
|
|
||||||
// Find client contact with matching email
|
// Find client contact with matching email
|
||||||
const contact = await db.query.clientContacts.findFirst({
|
const contact = await db.query.clientContacts.findFirst({
|
||||||
where: and(
|
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, normalizedEmail)),
|
||||||
eq(clientContacts.channel, 'email'),
|
|
||||||
eq(clientContacts.value, normalizedEmail),
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
client: true,
|
client: true,
|
||||||
},
|
},
|
||||||
@@ -95,11 +95,7 @@ export async function requestMagicLink(email: string): Promise<void> {
|
|||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(normalizedEmail, `Your ${portName} portal access link`, html);
|
||||||
normalizedEmail,
|
|
||||||
`Your ${portName} portal access link`,
|
|
||||||
html,
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info({ clientId: client.id, portId: client.portId }, 'Portal magic link sent');
|
logger.info({ clientId: client.id, portId: client.portId }, 'Portal magic link sent');
|
||||||
}
|
}
|
||||||
@@ -110,8 +106,7 @@ export interface PortalDashboard {
|
|||||||
client: {
|
client: {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
companyName: string | null;
|
nationality: string | null;
|
||||||
yachtName: string | null;
|
|
||||||
};
|
};
|
||||||
port: {
|
port: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -121,6 +116,9 @@ export interface PortalDashboard {
|
|||||||
interests: number;
|
interests: number;
|
||||||
documents: number;
|
documents: number;
|
||||||
invoices: number;
|
invoices: number;
|
||||||
|
yachts: number;
|
||||||
|
memberships: number;
|
||||||
|
activeReservations: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,23 +126,27 @@ export async function getPortalDashboard(
|
|||||||
clientId: string,
|
clientId: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
): Promise<PortalDashboard | null> {
|
): Promise<PortalDashboard | null> {
|
||||||
const [client, port, interestCount, documentCount] = await Promise.all([
|
const [client, port, interestCount, documentCount, yachtList, membershipList, reservationList] =
|
||||||
db.query.clients.findFirst({
|
await Promise.all([
|
||||||
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
db.query.clients.findFirst({
|
||||||
with: { contacts: true },
|
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
||||||
}),
|
with: { contacts: true },
|
||||||
db.query.ports.findFirst({
|
}),
|
||||||
where: eq(ports.id, portId),
|
db.query.ports.findFirst({
|
||||||
}),
|
where: eq(ports.id, portId),
|
||||||
db
|
}),
|
||||||
.select({ value: count() })
|
db
|
||||||
.from(interests)
|
.select({ value: count() })
|
||||||
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
|
.from(interests)
|
||||||
db
|
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
|
||||||
.select({ value: count() })
|
db
|
||||||
.from(documents)
|
.select({ value: count() })
|
||||||
.where(and(eq(documents.clientId, clientId), eq(documents.portId, portId))),
|
.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;
|
if (!client || !port) return null;
|
||||||
|
|
||||||
@@ -168,8 +170,7 @@ export async function getPortalDashboard(
|
|||||||
client: {
|
client: {
|
||||||
id: client.id,
|
id: client.id,
|
||||||
fullName: client.fullName,
|
fullName: client.fullName,
|
||||||
companyName: client.companyName ?? null,
|
nationality: client.nationality ?? null,
|
||||||
yachtName: client.yachtName ?? null,
|
|
||||||
},
|
},
|
||||||
port: {
|
port: {
|
||||||
name: port.name,
|
name: port.name,
|
||||||
@@ -179,6 +180,9 @@ export async function getPortalDashboard(
|
|||||||
interests: interestCount[0]?.value ?? 0,
|
interests: interestCount[0]?.value ?? 0,
|
||||||
documents: documentCount[0]?.value ?? 0,
|
documents: documentCount[0]?.value ?? 0,
|
||||||
invoices: invoiceCount,
|
invoices: invoiceCount,
|
||||||
|
yachts: yachtList.length,
|
||||||
|
memberships: membershipList.length,
|
||||||
|
activeReservations: reservationList.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -213,12 +217,7 @@ export async function getClientInterests(
|
|||||||
createdAt: interests.createdAt,
|
createdAt: interests.createdAt,
|
||||||
})
|
})
|
||||||
.from(interests)
|
.from(interests)
|
||||||
.where(
|
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId)))
|
||||||
and(
|
|
||||||
eq(interests.clientId, clientId),
|
|
||||||
eq(interests.portId, portId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(interests.createdAt);
|
.orderBy(interests.createdAt);
|
||||||
|
|
||||||
// Fetch berth details for interests that have a berth
|
// Fetch berth details for interests that have a berth
|
||||||
@@ -271,10 +270,7 @@ export async function getClientDocuments(
|
|||||||
portId: string,
|
portId: string,
|
||||||
): Promise<PortalDocument[]> {
|
): Promise<PortalDocument[]> {
|
||||||
const rows = await db.query.documents.findMany({
|
const rows = await db.query.documents.findMany({
|
||||||
where: and(
|
where: and(eq(documents.clientId, clientId), eq(documents.portId, portId)),
|
||||||
eq(documents.clientId, clientId),
|
|
||||||
eq(documents.portId, portId),
|
|
||||||
),
|
|
||||||
with: {
|
with: {
|
||||||
signers: true,
|
signers: true,
|
||||||
},
|
},
|
||||||
@@ -341,8 +337,7 @@ export async function getClientInvoices(
|
|||||||
.orderBy(invoices.createdAt);
|
.orderBy(invoices.createdAt);
|
||||||
|
|
||||||
const clientInvoices = allInvoices.filter(
|
const clientInvoices = allInvoices.filter(
|
||||||
(inv) =>
|
(inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
|
||||||
inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return clientInvoices.map((inv) => ({
|
return clientInvoices.map((inv) => ({
|
||||||
@@ -387,3 +382,207 @@ export async function getDocumentDownloadUrl(
|
|||||||
|
|
||||||
return getPresignedUrl(file.storagePath);
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
315
tests/unit/services/portal.test.ts
Normal file
315
tests/unit/services/portal.test.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPortalUserYachts,
|
||||||
|
getPortalUserMemberships,
|
||||||
|
getPortalUserReservations,
|
||||||
|
} from '@/lib/services/portal.service';
|
||||||
|
import {
|
||||||
|
makeClient,
|
||||||
|
makeCompany,
|
||||||
|
makeMembership,
|
||||||
|
makePort,
|
||||||
|
makeYacht,
|
||||||
|
makeBerth,
|
||||||
|
makeReservation,
|
||||||
|
} from '../../helpers/factories';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { yachts } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
describe('portal.service — getPortalUserYachts', () => {
|
||||||
|
it('returns client-owned yachts only when client has no memberships', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
name: 'Solo Sail',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserYachts(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.id).toBe(yacht.id);
|
||||||
|
expect(result[0]!.ownerContext).toBe('direct');
|
||||||
|
expect(result[0]!.ownerCompanyId).toBeNull();
|
||||||
|
expect(result[0]!.ownerCompanyName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns company-owned yachts when client is an active member', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const company = await makeCompany({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { name: `Acme Holdings ${Math.random().toString(36).slice(2, 6)}` },
|
||||||
|
});
|
||||||
|
await makeMembership({
|
||||||
|
companyId: company.id,
|
||||||
|
clientId: client.id,
|
||||||
|
role: 'director',
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'company',
|
||||||
|
ownerId: company.id,
|
||||||
|
name: 'Corporate Cruise',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserYachts(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.id).toBe(yacht.id);
|
||||||
|
expect(result[0]!.ownerContext).toBe('company');
|
||||||
|
expect(result[0]!.ownerCompanyId).toBe(company.id);
|
||||||
|
expect(result[0]!.ownerCompanyName).toBe(company.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-dupes if same yacht id appears in both paths (defensive)', async () => {
|
||||||
|
// A yacht cannot legitimately be owned by both, but we verify dedup is
|
||||||
|
// defensive by forcing the yacht's current owner to company after the
|
||||||
|
// direct query path has already cached it. We simulate the case by
|
||||||
|
// creating a client-owned yacht, then manually flipping owner to a
|
||||||
|
// company the client is a member of — if both queries ran they'd both
|
||||||
|
// match, but dedup by id ensures only one entry.
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const company = await makeCompany({ portId: port.id });
|
||||||
|
await makeMembership({
|
||||||
|
companyId: company.id,
|
||||||
|
clientId: client.id,
|
||||||
|
role: 'director',
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
name: 'Ambiguous',
|
||||||
|
});
|
||||||
|
// Flip the denormalized owner to the company (without updating history) —
|
||||||
|
// this is artificial but exercises the dedup branch.
|
||||||
|
await db
|
||||||
|
.update(yachts)
|
||||||
|
.set({ currentOwnerType: 'company', currentOwnerId: company.id })
|
||||||
|
.where(eq(yachts.id, yacht.id));
|
||||||
|
|
||||||
|
const result = await getPortalUserYachts(client.id, port.id);
|
||||||
|
const matches = result.filter((y) => y.id === yacht.id);
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes yachts from companies where membership ended', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const company = await makeCompany({ portId: port.id });
|
||||||
|
await makeMembership({
|
||||||
|
companyId: company.id,
|
||||||
|
clientId: client.id,
|
||||||
|
role: 'director',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2025-06-01'),
|
||||||
|
});
|
||||||
|
await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'company',
|
||||||
|
ownerId: company.id,
|
||||||
|
name: 'Past Company Yacht',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserYachts(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is tenant-scoped', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const clientInA = await makeClient({ portId: portA.id });
|
||||||
|
// Directly-owned yacht in portB with the SAME client id — must not leak
|
||||||
|
// because getPortalUserYachts filters on portId.
|
||||||
|
// We insert a yacht row in portB with ownerId=clientInA.id. The FK on
|
||||||
|
// yachts.currentOwnerId isn't to clients, so this is valid.
|
||||||
|
await makeYacht({
|
||||||
|
portId: portB.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: clientInA.id,
|
||||||
|
name: 'Other Port Yacht',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserYachts(clientInA.id, portA.id);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes archived yachts', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
overrides: { archivedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserYachts(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('portal.service — getPortalUserMemberships', () => {
|
||||||
|
it('returns only active memberships', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const activeCompany = await makeCompany({ portId: port.id });
|
||||||
|
const endedCompany = await makeCompany({ portId: port.id });
|
||||||
|
await makeMembership({
|
||||||
|
companyId: activeCompany.id,
|
||||||
|
clientId: client.id,
|
||||||
|
role: 'director',
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
await makeMembership({
|
||||||
|
companyId: endedCompany.id,
|
||||||
|
clientId: client.id,
|
||||||
|
role: 'officer',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2025-01-01'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserMemberships(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.company.id).toBe(activeCompany.id);
|
||||||
|
expect(result[0]!.role).toBe('director');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is tenant-scoped (memberships on companies in different port are excluded)', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const client = await makeClient({ portId: portA.id });
|
||||||
|
// Company in portB — but membership references clientId on portA.
|
||||||
|
const companyInB = await makeCompany({ portId: portB.id });
|
||||||
|
await makeMembership({
|
||||||
|
companyId: companyInB.id,
|
||||||
|
clientId: client.id,
|
||||||
|
role: 'director',
|
||||||
|
endDate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultA = await getPortalUserMemberships(client.id, portA.id);
|
||||||
|
expect(resultA).toHaveLength(0);
|
||||||
|
|
||||||
|
const resultB = await getPortalUserMemberships(client.id, portB.id);
|
||||||
|
expect(resultB).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('portal.service — getPortalUserReservations', () => {
|
||||||
|
it('returns active + pending reservations', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
});
|
||||||
|
const berth1 = await makeBerth({ portId: port.id });
|
||||||
|
const berth2 = await makeBerth({ portId: port.id });
|
||||||
|
await makeReservation({
|
||||||
|
berthId: berth1.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yacht.id,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
await makeReservation({
|
||||||
|
berthId: berth2.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yacht.id,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserReservations(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
const statuses = result.map((r) => r.status).sort();
|
||||||
|
expect(statuses).toEqual(['active', 'pending']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes ended and cancelled', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
});
|
||||||
|
const berth1 = await makeBerth({ portId: port.id });
|
||||||
|
const berth2 = await makeBerth({ portId: port.id });
|
||||||
|
await makeReservation({
|
||||||
|
berthId: berth1.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yacht.id,
|
||||||
|
status: 'ended',
|
||||||
|
});
|
||||||
|
await makeReservation({
|
||||||
|
berthId: berth2.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yacht.id,
|
||||||
|
status: 'cancelled',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserReservations(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is tenant-scoped', async () => {
|
||||||
|
const portA = await makePort();
|
||||||
|
const portB = await makePort();
|
||||||
|
const client = await makeClient({ portId: portA.id });
|
||||||
|
const yachtB = await makeYacht({
|
||||||
|
portId: portB.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
});
|
||||||
|
const berthB = await makeBerth({ portId: portB.id });
|
||||||
|
await makeReservation({
|
||||||
|
berthId: berthB.id,
|
||||||
|
portId: portB.id,
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yachtB.id,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultA = await getPortalUserReservations(client.id, portA.id);
|
||||||
|
expect(resultA).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes joined yacht name + berth mooring number', async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
const client = await makeClient({ portId: port.id });
|
||||||
|
const yacht = await makeYacht({
|
||||||
|
portId: port.id,
|
||||||
|
ownerType: 'client',
|
||||||
|
ownerId: client.id,
|
||||||
|
name: 'Test Vessel',
|
||||||
|
});
|
||||||
|
const berth = await makeBerth({
|
||||||
|
portId: port.id,
|
||||||
|
overrides: { mooringNumber: 'M-42' },
|
||||||
|
});
|
||||||
|
await makeReservation({
|
||||||
|
berthId: berth.id,
|
||||||
|
portId: port.id,
|
||||||
|
clientId: client.id,
|
||||||
|
yachtId: yacht.id,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await getPortalUserReservations(client.id, port.id);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.yachtName).toBe('Test Vessel');
|
||||||
|
expect(result[0]!.berthMooringNumber).toBe('M-42');
|
||||||
|
expect(result[0]!.tenureType).toBe('permanent');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user