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 { 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() {
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
||||
</h1>
|
||||
{dashboard.client.companyName && (
|
||||
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
|
||||
)}
|
||||
{dashboard.client.yachtName && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
|
||||
{dashboard.client.nationality && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">{dashboard.client.nationality}</p>
|
||||
)}
|
||||
</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
|
||||
title="Berth Interests"
|
||||
value={dashboard.counts.interests}
|
||||
@@ -51,13 +48,33 @@ export default async function PortalDashboardPage() {
|
||||
icon={Receipt}
|
||||
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 className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
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.
|
||||
</p>
|
||||
</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 { 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 },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, count } from 'drizzle-orm';
|
||||
import { and, eq, count, inArray, isNull, desc } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
@@ -7,6 +7,9 @@ import { documents, files } from '@/lib/db/schema/documents';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { createPortalToken } from '@/lib/portal/auth';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { getPresignedUrl } from '@/lib/minio';
|
||||
@@ -24,10 +27,7 @@ export async function requestMagicLink(email: string): Promise<void> {
|
||||
|
||||
// Find client contact with matching email
|
||||
const contact = await db.query.clientContacts.findFirst({
|
||||
where: and(
|
||||
eq(clientContacts.channel, 'email'),
|
||||
eq(clientContacts.value, normalizedEmail),
|
||||
),
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, normalizedEmail)),
|
||||
with: {
|
||||
client: true,
|
||||
},
|
||||
@@ -95,11 +95,7 @@ export async function requestMagicLink(email: string): Promise<void> {
|
||||
</html>
|
||||
`;
|
||||
|
||||
await sendEmail(
|
||||
normalizedEmail,
|
||||
`Your ${portName} portal access link`,
|
||||
html,
|
||||
);
|
||||
await sendEmail(normalizedEmail, `Your ${portName} portal access link`, html);
|
||||
|
||||
logger.info({ clientId: client.id, portId: client.portId }, 'Portal magic link sent');
|
||||
}
|
||||
@@ -110,8 +106,7 @@ export interface PortalDashboard {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
companyName: string | null;
|
||||
yachtName: string | null;
|
||||
nationality: string | null;
|
||||
};
|
||||
port: {
|
||||
name: string;
|
||||
@@ -121,6 +116,9 @@ export interface PortalDashboard {
|
||||
interests: number;
|
||||
documents: number;
|
||||
invoices: number;
|
||||
yachts: number;
|
||||
memberships: number;
|
||||
activeReservations: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +126,8 @@ export async function getPortalDashboard(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
): Promise<PortalDashboard | null> {
|
||||
const [client, port, interestCount, documentCount] = await Promise.all([
|
||||
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 },
|
||||
@@ -144,6 +143,9 @@ export async function getPortalDashboard(
|
||||
.select({ value: count() })
|
||||
.from(documents)
|
||||
.where(and(eq(documents.clientId, clientId), eq(documents.portId, portId))),
|
||||
getPortalUserYachts(clientId, portId),
|
||||
getPortalUserMemberships(clientId, portId),
|
||||
getPortalUserReservations(clientId, portId),
|
||||
]);
|
||||
|
||||
if (!client || !port) return null;
|
||||
@@ -168,8 +170,7 @@ export async function getPortalDashboard(
|
||||
client: {
|
||||
id: client.id,
|
||||
fullName: client.fullName,
|
||||
companyName: client.companyName ?? null,
|
||||
yachtName: client.yachtName ?? null,
|
||||
nationality: client.nationality ?? null,
|
||||
},
|
||||
port: {
|
||||
name: port.name,
|
||||
@@ -179,6 +180,9 @@ export async function getPortalDashboard(
|
||||
interests: interestCount[0]?.value ?? 0,
|
||||
documents: documentCount[0]?.value ?? 0,
|
||||
invoices: invoiceCount,
|
||||
yachts: yachtList.length,
|
||||
memberships: membershipList.length,
|
||||
activeReservations: reservationList.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -213,12 +217,7 @@ export async function getClientInterests(
|
||||
createdAt: interests.createdAt,
|
||||
})
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.clientId, clientId),
|
||||
eq(interests.portId, portId),
|
||||
),
|
||||
)
|
||||
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId)))
|
||||
.orderBy(interests.createdAt);
|
||||
|
||||
// Fetch berth details for interests that have a berth
|
||||
@@ -271,10 +270,7 @@ export async function getClientDocuments(
|
||||
portId: string,
|
||||
): Promise<PortalDocument[]> {
|
||||
const rows = await db.query.documents.findMany({
|
||||
where: and(
|
||||
eq(documents.clientId, clientId),
|
||||
eq(documents.portId, portId),
|
||||
),
|
||||
where: and(eq(documents.clientId, clientId), eq(documents.portId, portId)),
|
||||
with: {
|
||||
signers: true,
|
||||
},
|
||||
@@ -341,8 +337,7 @@ export async function getClientInvoices(
|
||||
.orderBy(invoices.createdAt);
|
||||
|
||||
const clientInvoices = allInvoices.filter(
|
||||
(inv) =>
|
||||
inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
|
||||
(inv) => inv.billingEmail && emailContacts.includes(inv.billingEmail.toLowerCase()),
|
||||
);
|
||||
|
||||
return clientInvoices.map((inv) => ({
|
||||
@@ -387,3 +382,207 @@ export async function getDocumentDownloadUrl(
|
||||
|
||||
return getPresignedUrl(file.storagePath);
|
||||
}
|
||||
|
||||
// ─── Yachts (direct + via company) ────────────────────────────────────────────
|
||||
|
||||
export interface PortalYacht {
|
||||
id: string;
|
||||
name: string;
|
||||
hullNumber: string | null;
|
||||
registration: string | null;
|
||||
flag: string | null;
|
||||
yearBuilt: number | null;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
status: string;
|
||||
ownerContext: 'direct' | 'company';
|
||||
ownerCompanyId: string | null;
|
||||
ownerCompanyName: string | null;
|
||||
}
|
||||
|
||||
function toPortalYacht(y: typeof yachts.$inferSelect): PortalYacht {
|
||||
return {
|
||||
id: y.id,
|
||||
name: y.name,
|
||||
hullNumber: y.hullNumber,
|
||||
registration: y.registration,
|
||||
flag: y.flag,
|
||||
yearBuilt: y.yearBuilt,
|
||||
lengthFt: y.lengthFt,
|
||||
widthFt: y.widthFt,
|
||||
status: y.status,
|
||||
ownerContext: 'direct',
|
||||
ownerCompanyId: null,
|
||||
ownerCompanyName: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPortalUserYachts(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
): Promise<PortalYacht[]> {
|
||||
// 1. Direct yachts
|
||||
const directYachts = await db.query.yachts.findMany({
|
||||
where: and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
eq(yachts.currentOwnerId, clientId),
|
||||
isNull(yachts.archivedAt),
|
||||
),
|
||||
});
|
||||
|
||||
// 2. Active company memberships
|
||||
const memberships = await db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.clientId, clientId),
|
||||
eq(companies.portId, portId),
|
||||
isNull(companyMemberships.endDate),
|
||||
),
|
||||
);
|
||||
|
||||
const companyIds = memberships.map((m) => m.companyId);
|
||||
|
||||
// De-dup by yacht.id
|
||||
const seen = new Set<string>();
|
||||
const result: PortalYacht[] = [];
|
||||
|
||||
for (const y of directYachts) {
|
||||
if (seen.has(y.id)) continue;
|
||||
seen.add(y.id);
|
||||
result.push(toPortalYacht(y));
|
||||
}
|
||||
|
||||
if (companyIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. Company-owned yachts
|
||||
const companyYachts = await db
|
||||
.select({
|
||||
yacht: yachts,
|
||||
company: { id: companies.id, name: companies.name },
|
||||
})
|
||||
.from(yachts)
|
||||
.innerJoin(companies, eq(yachts.currentOwnerId, companies.id))
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'company'),
|
||||
inArray(yachts.currentOwnerId, companyIds),
|
||||
isNull(yachts.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of companyYachts) {
|
||||
if (seen.has(row.yacht.id)) continue;
|
||||
seen.add(row.yacht.id);
|
||||
result.push({
|
||||
id: row.yacht.id,
|
||||
name: row.yacht.name,
|
||||
hullNumber: row.yacht.hullNumber,
|
||||
registration: row.yacht.registration,
|
||||
flag: row.yacht.flag,
|
||||
yearBuilt: row.yacht.yearBuilt,
|
||||
lengthFt: row.yacht.lengthFt,
|
||||
widthFt: row.yacht.widthFt,
|
||||
status: row.yacht.status,
|
||||
ownerContext: 'company',
|
||||
ownerCompanyId: row.company.id,
|
||||
ownerCompanyName: row.company.name,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Memberships ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PortalMembership {
|
||||
membershipId: string;
|
||||
role: string;
|
||||
isPrimary: boolean;
|
||||
startDate: Date;
|
||||
company: {
|
||||
id: string;
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPortalUserMemberships(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
): Promise<PortalMembership[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
membershipId: companyMemberships.id,
|
||||
role: companyMemberships.role,
|
||||
isPrimary: companyMemberships.isPrimary,
|
||||
startDate: companyMemberships.startDate,
|
||||
company: {
|
||||
id: companies.id,
|
||||
name: companies.name,
|
||||
legalName: companies.legalName,
|
||||
status: companies.status,
|
||||
},
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.clientId, clientId),
|
||||
eq(companies.portId, portId),
|
||||
isNull(companyMemberships.endDate),
|
||||
),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Reservations ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PortalReservation {
|
||||
id: string;
|
||||
berthId: string;
|
||||
berthMooringNumber: string | null;
|
||||
yachtId: string;
|
||||
yachtName: string | null;
|
||||
status: 'pending' | 'active' | 'ended' | 'cancelled';
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
tenureType: string;
|
||||
}
|
||||
|
||||
export async function getPortalUserReservations(
|
||||
clientId: string,
|
||||
portId: string,
|
||||
): Promise<PortalReservation[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: berthReservations.id,
|
||||
berthId: berthReservations.berthId,
|
||||
berthMooringNumber: berths.mooringNumber,
|
||||
yachtId: berthReservations.yachtId,
|
||||
yachtName: yachts.name,
|
||||
status: berthReservations.status,
|
||||
startDate: berthReservations.startDate,
|
||||
endDate: berthReservations.endDate,
|
||||
tenureType: berthReservations.tenureType,
|
||||
})
|
||||
.from(berthReservations)
|
||||
.innerJoin(berths, eq(berthReservations.berthId, berths.id))
|
||||
.innerJoin(yachts, eq(berthReservations.yachtId, yachts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(berthReservations.clientId, clientId),
|
||||
eq(berthReservations.portId, portId),
|
||||
inArray(berthReservations.status, ['pending', 'active']),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(berthReservations.createdAt));
|
||||
return rows as PortalReservation[];
|
||||
}
|
||||
|
||||
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