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,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>

View 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>
);
}

View 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>
);
}

View File

@@ -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 },
]; ];

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 { 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[];
}

View 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');
});
});