From a14dc8143ce38db7e8fed14eb73f14036be34c9f Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 14:43:12 +0200 Subject: [PATCH] feat(portal): surface yachts, memberships, reservations for portal users Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(portal)/portal/dashboard/page.tsx | 35 +- .../(portal)/portal/my-reservations/page.tsx | 83 +++++ src/app/(portal)/portal/my-yachts/page.tsx | 77 +++++ src/components/portal/portal-nav.tsx | 4 +- src/lib/services/portal.service.ts | 285 +++++++++++++--- tests/unit/services/portal.test.ts | 315 ++++++++++++++++++ 6 files changed, 746 insertions(+), 53 deletions(-) create mode 100644 src/app/(portal)/portal/my-reservations/page.tsx create mode 100644 src/app/(portal)/portal/my-yachts/page.tsx create mode 100644 tests/unit/services/portal.test.ts diff --git a/src/app/(portal)/portal/dashboard/page.tsx b/src/app/(portal)/portal/dashboard/page.tsx index 8095a63..c5d8e6e 100644 --- a/src/app/(portal)/portal/dashboard/page.tsx +++ b/src/app/(portal)/portal/dashboard/page.tsx @@ -1,5 +1,5 @@ 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 { getPortalSession } from '@/lib/portal/auth'; @@ -21,15 +21,12 @@ export default async function PortalDashboardPage() {

Welcome back, {dashboard.client.fullName.split(' ')[0]}

- {dashboard.client.companyName && ( -

{dashboard.client.companyName}

- )} - {dashboard.client.yachtName && ( -

Vessel: {dashboard.client.yachtName}

+ {dashboard.client.nationality && ( +

{dashboard.client.nationality}

)} -
+
+ + +

Need assistance?

- Contact the {dashboard.port.name} team directly. This portal provides a read-only view - of your account. All changes must be made through your port contact. + Contact the {dashboard.port.name} team directly. This portal provides a read-only view of + your account. All changes must be made through your port contact.

diff --git a/src/app/(portal)/portal/my-reservations/page.tsx b/src/app/(portal)/portal/my-reservations/page.tsx new file mode 100644 index 0000000..95db972 --- /dev/null +++ b/src/app/(portal)/portal/my-reservations/page.tsx @@ -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 = { + pending: 'secondary', + active: 'default', + ended: 'outline', + cancelled: 'destructive', +}; + +const TENURE_LABELS: Record = { + 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 ( +
+
+

My Reservations

+

Your current and pending berth reservations

+
+ + {reservations.length === 0 ? ( +
+ +

No active reservations

+

+ Contact your port representative to discuss reservations. +

+
+ ) : ( +
+ {reservations.map((r) => ( +
+
+
+
+ {r.yachtName ?? 'Yacht'} + {r.berthMooringNumber && ( + — Berth {r.berthMooringNumber} + )} +
+

+ {TENURE_LABELS[r.tenureType] ?? r.tenureType} +

+
+ + From {formatDate(r.startDate)} + {r.endDate ? ` to ${formatDate(r.endDate)}` : ' · ongoing'} + +
+
+ {r.status} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(portal)/portal/my-yachts/page.tsx b/src/app/(portal)/portal/my-yachts/page.tsx new file mode 100644 index 0000000..a96865b --- /dev/null +++ b/src/app/(portal)/portal/my-yachts/page.tsx @@ -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 = { + 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 ( +
+
+

My Yachts

+

Vessels you own directly or through a company

+
+ + {yachts.length === 0 ? ( +
+ +

No yachts on file

+

+ Yachts owned by you or a company you are a member of will appear here. +

+
+ ) : ( +
+ {yachts.map((y) => ( +
+
+ +
+
+
+

{y.name}

+

+ {y.hullNumber ? `Hull ${y.hullNumber}` : 'No hull number'} + {y.flag ? ` · ${y.flag}` : ''} + {y.yearBuilt ? ` · ${y.yearBuilt}` : ''} +

+ {y.ownerContext === 'company' && y.ownerCompanyName && ( +

Owned by {y.ownerCompanyName}

+ )} +
+ + {y.status.replace(/_/g, ' ')} + +
+ + {(y.lengthFt || y.widthFt || y.registration) && ( +
+ {y.registration && Reg: {y.registration}} + {y.lengthFt && Length: {y.lengthFt}ft} + {y.widthFt && Beam: {y.widthFt}ft} +
+ )} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/portal/portal-nav.tsx b/src/components/portal/portal-nav.tsx index 0aae37a..a6f156d 100644 --- a/src/components/portal/portal-nav.tsx +++ b/src/components/portal/portal-nav.tsx @@ -2,12 +2,14 @@ import Link from 'next/link'; 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'; const navItems = [ { label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard }, { 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: 'Invoices', href: '/portal/invoices', icon: Receipt }, ]; diff --git a/src/lib/services/portal.service.ts b/src/lib/services/portal.service.ts index 3ae7a36..b925240 100644 --- a/src/lib/services/portal.service.ts +++ b/src/lib/services/portal.service.ts @@ -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 { // 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 { `; - 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 { - 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 { 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 { + // 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(); + 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 { + 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 { + 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[]; +} diff --git a/tests/unit/services/portal.test.ts b/tests/unit/services/portal.test.ts new file mode 100644 index 0000000..a22bdbe --- /dev/null +++ b/tests/unit/services/portal.test.ts @@ -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'); + }); +});