feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped

Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-27 21:54:32 +02:00
parent fac8021156
commit e8d61c91c4
121 changed files with 34105 additions and 1016 deletions

View File

@@ -8,14 +8,5 @@ export const metadata: Metadata = {
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div
className="min-h-screen flex items-center justify-center wave-watermark"
style={{ backgroundColor: '#1e2844' }}
>
<div className="w-full max-w-md px-4">
{children}
</div>
</div>
);
return <>{children}</>;
}

View File

@@ -10,9 +10,9 @@ import { toast } from 'sonner';
import { authClient } from '@/lib/auth/client';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
@@ -55,64 +55,53 @@ export default function LoginPage() {
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Marina CRM</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Port Nimara CRM</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to continue</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/reset-password"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
disabled={isLoading}
className={cn(
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password')}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
{...register('email')}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</CardContent>
</Card>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="/reset-password" className="text-xs text-[#007bff] hover:underline">
Forgot password?
</Link>
</div>
<Input
id="password"
type="password"
autoComplete="current-password"
disabled={isLoading}
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
{...register('password')}
/>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</BrandedAuthShell>
);
}

View File

@@ -7,9 +7,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
import { cn } from '@/lib/utils';
const resetSchema = z.object({
@@ -49,69 +49,55 @@ export default function ResetPasswordPage() {
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Reset your password</p>
</CardHeader>
<CardContent>
{submitted ? (
<div className="space-y-4 text-center">
<div className="space-y-2">
<p className="font-medium text-foreground">Check your email</p>
<p className="text-sm text-muted-foreground">
If an account exists for that email address, we have sent a password reset link.
Please check your inbox and spam folder.
</p>
</div>
<Link
href="/login"
className="inline-block text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Back to sign in
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(
errors.email && 'border-destructive focus-visible:ring-destructive',
)}
{...register('email')}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">We&apos;ll email you a link</p>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Sending…' : 'Send reset link'}
</Button>
{submitted ? (
<div className="space-y-4 text-center">
<p className="font-medium text-gray-900">Check your email</p>
<p className="text-sm text-gray-500">
If an account exists for that email address, we have sent a password reset link. Please
check your inbox and spam folder.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
Back to sign in
</Link>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
disabled={isLoading}
className={cn(errors.email && 'border-destructive focus-visible:ring-destructive')}
{...register('email')}
/>
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
</div>
<p className="text-center text-sm text-muted-foreground">
Remember your password?{' '}
<Link
href="/login"
className="text-foreground underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</form>
)}
</CardContent>
</Card>
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Sending…' : 'Send reset link'}
</Button>
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/login" className="text-[#007bff] hover:underline">
Sign in
</Link>
</p>
</form>
)}
</BrandedAuthShell>
);
}

View File

@@ -1,27 +1,23 @@
'use client';
import { Suspense, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { CheckCircle2, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
const MIN_LENGTH = 9;
const passwordSchema = z
.object({
password: z
.string()
.min(12, 'Must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[a-z]/, 'Must contain a lowercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
password: z.string().min(MIN_LENGTH, `Must be at least ${MIN_LENGTH} characters`),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
@@ -31,25 +27,11 @@ const passwordSchema = z
type SetPasswordFormData = z.infer<typeof passwordSchema>;
type Requirement = {
label: string;
test: (value: string) => boolean;
};
const requirements: Requirement[] = [
{ label: 'At least 12 characters', test: (v) => v.length >= 12 },
{ label: 'Uppercase letter', test: (v) => /[A-Z]/.test(v) },
{ label: 'Lowercase letter', test: (v) => /[a-z]/.test(v) },
{ label: 'Number', test: (v) => /[0-9]/.test(v) },
{ label: 'Special character', test: (v) => /[^A-Za-z0-9]/.test(v) },
];
function SetPasswordInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(false);
const [passwordValue, setPasswordValue] = useState('');
const {
register,
@@ -61,7 +43,7 @@ function SetPasswordInner() {
async function onSubmit(data: SetPasswordFormData) {
if (!token) {
toast.error('Invalid or missing reset token. Please request a new password reset link.');
toast.error('Invalid or missing reset token. Please request a new link.');
return;
}
@@ -75,7 +57,7 @@ function SetPasswordInner() {
if (!response.ok) {
const body = await response.json().catch(() => ({}));
toast.error(body.message ?? 'Failed to set password. Please try again.');
toast.error(body.message ?? body.error ?? 'Failed to set password. Please try again.');
return;
}
@@ -88,102 +70,77 @@ function SetPasswordInner() {
}
}
if (!token) {
return (
<BrandedAuthShell>
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold text-gray-900">Link is missing or invalid</h1>
<p className="text-sm text-gray-500">
Please use the link from the email we sent you. If the link is broken, ask your
administrator for a new one.
</p>
<Link href="/login" className="inline-block text-sm text-[#007bff] hover:underline">
Back to sign in
</Link>
</div>
</BrandedAuthShell>
);
}
return (
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
>
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center pb-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground">Port Nimara</h1>
<p className="text-sm text-muted-foreground">Set your password</p>
</CardHeader>
<CardContent>
{!token ? (
<p className="text-center text-sm text-destructive">
Invalid or missing token. Please request a new password reset link.
</p>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.password && 'border-destructive focus-visible:ring-destructive',
)}
{...register('password', {
onChange: (e) => setPasswordValue(e.target.value),
})}
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password.message}</p>
)}
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Set your password</h1>
<p className="text-sm text-gray-500 mt-1">Choose a password for your CRM account</p>
</div>
<ul className="space-y-1 pt-1">
{requirements.map((req) => {
const met = req.test(passwordValue);
return (
<li
key={req.label}
className={cn(
'flex items-center gap-2 text-xs',
met ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground',
)}
>
{met ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0" />
)}
{req.label}
</li>
);
})}
</ul>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-1.5">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(errors.password && 'border-destructive focus-visible:ring-destructive')}
{...register('password')}
/>
<p className="text-xs text-gray-500">At least {MIN_LENGTH} characters.</p>
{errors.password && <p className="text-sm text-destructive">{errors.password.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
)}
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Setting password…' : 'Set password'}
</Button>
</form>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Confirm password</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
disabled={isLoading}
className={cn(
errors.confirmPassword && 'border-destructive focus-visible:ring-destructive',
)}
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
)}
</CardContent>
</Card>
</div>
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={isLoading}
>
{isLoading ? 'Setting password…' : 'Set password'}
</Button>
</form>
</BrandedAuthShell>
);
}
export default function SetPasswordPage() {
return (
<Suspense
fallback={
<div
className="min-h-screen flex items-center justify-center px-4"
style={{ backgroundColor: '#1e2844' }}
/>
}
>
<Suspense fallback={<BrandedAuthShell>{null}</BrandedAuthShell>}>
<SetPasswordInner />
</Suspense>
);

View File

@@ -1,16 +1,5 @@
import { FormTemplateList } from '@/components/admin/forms/form-template-list';
export default function FormTemplatesPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Form Templates</h1>
<p className="text-muted-foreground">Create and manage intake form templates</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
</div>
</div>
);
return <FormTemplateList />;
}

View File

@@ -0,0 +1,5 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
export default function DashboardPage() {
return <DashboardShell />;
}

View File

@@ -1,16 +1,47 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { EmailAccountsList } from '@/components/email/email-accounts-list';
import { EmailThreadsList } from '@/components/email/email-threads-list';
import { ComposeDialog } from '@/components/email/compose-dialog';
export default function EmailPage() {
const [tab, setTab] = useState('threads');
const [composeOpen, setComposeOpen] = useState(false);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Email</h1>
<p className="text-muted-foreground">Send and manage client communications</p>
</div>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 3</p>
<p className="text-sm text-muted-foreground">
This feature will be implemented in the next phase.
</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground">Email</h1>
<p className="text-muted-foreground">Send and manage client communications</p>
</div>
<Button onClick={() => setComposeOpen(true)}>
<Send className="h-4 w-4 mr-1.5" />
Compose
</Button>
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="threads">Inbox</TabsTrigger>
<TabsTrigger value="accounts">Accounts</TabsTrigger>
</TabsList>
<TabsContent value="threads" className="pt-4">
<EmailThreadsList />
</TabsContent>
<TabsContent value="accounts" className="pt-4">
<EmailAccountsList />
</TabsContent>
</Tabs>
<ComposeDialog open={composeOpen} onOpenChange={setComposeOpen} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
export default function NotificationPreferencesPage() {
return (
<div className="max-w-2xl mx-auto py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold">Notification Preferences</h1>
<p className="text-sm text-muted-foreground">
Choose which notifications you receive and how.
</p>
</div>
<NotificationPreferencesForm />
</div>
);
}

View File

@@ -1,5 +1,7 @@
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
import { redirect } from 'next/navigation';
export default function DashboardPage() {
return <DashboardShell />;
export default async function PortIndexPage({ params }: { params: Promise<{ portSlug: string }> }) {
const { portSlug } = await params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/${portSlug}/dashboard` as any);
}

View File

@@ -0,0 +1,10 @@
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function ResidentialClientDetailPage({ params }: Props) {
const { id } = await params;
return <ResidentialClientDetail clientId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { ResidentialClientsList } from '@/components/residential/residential-clients-list';
export default function ResidentialClientsPage() {
return <ResidentialClientsList />;
}

View File

@@ -0,0 +1,10 @@
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
interface Props {
params: Promise<{ id: string }>;
}
export default async function ResidentialInterestDetailPage({ params }: Props) {
const { id } = await params;
return <ResidentialInterestDetail interestId={id} />;
}

View File

@@ -0,0 +1,5 @@
import { ResidentialInterestsList } from '@/components/residential/residential-interests-list';
export default function ResidentialInterestsPage() {
return <ResidentialInterestsList />;
}

View File

@@ -4,7 +4,8 @@ import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userPortRoles } from '@/lib/db/schema/users';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
import { QueryProvider } from '@/providers/query-provider';
import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
@@ -16,26 +17,31 @@ export default async function DashboardLayout({ children }: { children: React.Re
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
// Load user's port assignments for PortProvider
// Super admins have implicit access to every port; everyone else only sees
// ports they have an explicit user_port_roles row for.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
const portRoles = await db.query.userPortRoles.findMany({
where: eq(userPortRoles.userId, session.user.id),
with: { port: true, role: true },
});
const ports = portRoles.map((pr) => pr.port);
const ports = profile?.isSuperAdmin
? await db.query.ports.findMany({ orderBy: portsTable.name })
: portRoles.map((pr) => pr.port);
return (
<QueryProvider>
<PortProvider ports={ports} defaultPortId={portRoles[0]?.port.id ?? null}>
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
<PermissionsProvider>
<SocketProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar portRoles={portRoles} />
<Sidebar portRoles={portRoles} isSuperAdmin={profile?.isSuperAdmin ?? false} />
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar ports={ports} />
<main className="flex-1 overflow-y-auto bg-background p-6">
{children}
</main>
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
</div>
</div>
</SocketProvider>

View File

@@ -2,11 +2,12 @@
import Link from 'next/link';
import { useState } from 'react';
import { Loader2, Mail } from 'lucide-react';
import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
export default function PortalForgotPasswordPage() {
const [email, setEmail] = useState('');
@@ -31,77 +32,74 @@ export default function PortalForgotPasswordPage() {
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md text-center">
<BrandedAuthShell>
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
<Mail className="h-7 w-7 text-green-600" />
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
<p className="text-gray-500 text-sm leading-relaxed">
<p className="text-sm text-gray-500 leading-relaxed">
If <strong>{email}</strong> matches a portal account, we&apos;ve sent a reset link. The
link expires in 30 minutes.
</p>
<Link
href="/portal/login"
className="mt-6 inline-block text-sm text-[#1e2844] hover:underline"
className="mt-6 inline-block text-sm text-[#007bff] hover:underline"
>
Back to sign in
</Link>
</div>
</div>
</BrandedAuthShell>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg border p-8 shadow-sm">
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
disabled={loading}
/>
</div>
<Button
type="submit"
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
'Send reset link'
)}
</Button>
</form>
<Link
href="/portal/login"
className="block mt-4 text-center text-xs text-gray-500 hover:underline"
>
Back to sign in
</Link>
</div>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Reset your password</h1>
<p className="text-sm text-gray-500 mt-1">
Enter your email and we&apos;ll send you a reset link.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="email"
disabled={loading}
/>
</div>
<Button
type="submit"
className="w-full bg-[#007bff] hover:bg-[#0069d9] text-white"
disabled={loading || !email}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
'Send reset link'
)}
</Button>
<p className="text-center text-sm text-gray-500">
Remember your password?{' '}
<Link href="/portal/login" className="text-[#007bff] hover:underline">
Sign in
</Link>
</p>
</form>
</BrandedAuthShell>
);
}

View File

@@ -8,7 +8,7 @@ import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PortalAuthShell } from '@/components/portal/portal-auth-shell';
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
export default function PortalLoginPage() {
const router = useRouter();
@@ -49,7 +49,7 @@ export default function PortalLoginPage() {
}
return (
<PortalAuthShell>
<BrandedAuthShell>
<div className="text-center mb-6">
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
<p className="text-sm text-gray-500 mt-1">Sign in to your account</p>
@@ -110,6 +110,6 @@ export default function PortalLoginPage() {
<p className="text-center text-xs text-gray-400 mt-6">
This portal is for existing clients only.
</p>
</PortalAuthShell>
</BrandedAuthShell>
);
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
});
export async function POST(req: NextRequest): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ message: 'Invalid request body' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ message: parsed.error.errors[0]?.message ?? 'Invalid input' },
{ status: 400 },
);
}
try {
const result = await consumeCrmInvite({
token: parsed.data.token,
password: parsed.data.password,
});
return NextResponse.json({ success: true, email: result.email });
} catch (err) {
return errorResponse(err);
}
}

View File

@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { withTransaction } from '@/lib/db/utils';
import { ports } from '@/lib/db/schema/ports';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { systemSettings } from '@/lib/db/schema/system';
import { sendEmail } from '@/lib/email';
import {
residentialClientConfirmation,
residentialSalesAlert,
} from '@/lib/email/templates/residential-inquiry';
import { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
import { emitToRoom } from '@/lib/socket/server';
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000;
const MAX_HITS = 5;
function checkRateLimit(ip: string): void {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now > entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return;
}
if (entry.count >= MAX_HITS) {
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
}
entry.count += 1;
}
/**
* POST /api/public/residential-inquiries — unauthenticated entry point for
* the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a
* single transaction.
*
* Required: `portId` query param or `X-Port-Id` header.
*/
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
checkRateLimit(ip);
const body = await req.json();
const data = publicResidentialInquirySchema.parse(body);
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
if (!portId) {
throw new ValidationError('portId is required');
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
if (!port) {
throw new ValidationError('Unknown port');
}
const result = await withTransaction(async (tx) => {
const [client] = await tx
.insert(residentialClients)
.values({
portId,
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
email: data.email,
phone: data.phone,
placeOfResidence: data.placeOfResidence,
preferredContactMethod: data.preferredContactMethod,
source: 'website',
status: 'prospect',
notes: data.notes,
})
.returning();
if (!client) throw new Error('Failed to create residential client');
const [interest] = await tx
.insert(residentialInterests)
.values({
portId,
residentialClientId: client.id,
pipelineStage: 'new',
source: 'website',
notes: data.notes,
preferences: data.preferences,
})
.returning();
if (!interest) throw new Error('Failed to create residential interest');
return { clientId: client.id, interestId: interest.id };
});
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking — failures shouldn't 500 the
// public form).
void sendResidentialNotifications({
portId,
data,
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
return NextResponse.json({ success: true, ...result }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
async function sendResidentialNotifications(args: {
portId: string;
data: {
firstName: string;
lastName: string;
email: string;
phone: string;
placeOfResidence?: string;
preferredContactMethod?: 'email' | 'phone';
notes?: string;
preferences?: string;
};
crmDeepLink: string;
}): Promise<void> {
const { portId, data, crmDeepLink } = args;
// Client confirmation
const confirmation = residentialClientConfirmation({
firstName: data.firstName,
contactEmail: 'sales@portnimara.com',
});
await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert — pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({
where: and(
eq(systemSettings.key, 'residential_notification_recipients'),
eq(systemSettings.portId, portId),
),
});
const fallbackRow = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'inquiry_contact_email'), eq(systemSettings.portId, portId)),
});
const configured = Array.isArray(recipientsRow?.value) ? (recipientsRow!.value as string[]) : [];
const fallback =
typeof fallbackRow?.value === 'string' && fallbackRow.value.length > 0
? [fallbackRow.value]
: [];
const recipients = configured.length > 0 ? configured : fallback;
if (recipients.length === 0) {
logger.warn(
{ portId },
'No residential_notification_recipients or inquiry_contact_email configured; skipping sales alert',
);
return;
}
const alert = residentialSalesAlert({
fullName: `${data.firstName} ${data.lastName}`.trim(),
email: data.email,
phone: data.phone,
placeOfResidence: data.placeOfResidence,
preferredContactMethod: data.preferredContactMethod,
notes: data.notes,
preferences: data.preferences,
crmDeepLink,
});
await sendEmail(recipients, alert.subject, alert.html);
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteFormTemplate,
getFormTemplateById,
updateFormTemplate,
} from '@/lib/services/form-templates.service';
import { updateFormTemplateSchema } from '@/lib/validators/form-templates';
export const GET = withAuth(
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
try {
if (!params.id) throw new NotFoundError('Form template');
const tpl = await getFormTemplateById(params.id, ctx.portId);
return NextResponse.json({ data: tpl });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_forms', async (req, ctx, params) => {
try {
if (!params.id) throw new NotFoundError('Form template');
const body = await parseBody(req, updateFormTemplateSchema);
const tpl = await updateFormTemplate(params.id, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: tpl });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('admin', 'manage_forms', async (_req, ctx, params) => {
try {
if (!params.id) throw new NotFoundError('Form template');
await deleteFormTemplate(params.id, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createFormTemplate, listFormTemplates } from '@/lib/services/form-templates.service';
import { createFormTemplateSchema } from '@/lib/validators/form-templates';
export const GET = withAuth(
withPermission('admin', 'manage_forms', async (_req, ctx) => {
try {
const data = await listFormTemplates(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_forms', async (req, ctx) => {
try {
const body = await parseBody(req, createFormTemplateSchema);
const tpl = await createFormTemplate(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: tpl }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,65 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { NotFoundError, errorResponse } from '@/lib/errors';
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
// URL berthId is authoritative; make body berthId optional (ignored anyway).
const createPendingBodySchema = createPendingSchema
.omit({ berthId: true })
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
}
export const listHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const query = parseQuery(req, listReservationsSchema);
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const body = await parseBody(req, createPendingBodySchema);
const reservation = await createPending(
ctx.portId,
{ ...body, berthId: params.id! },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: reservation }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -1,72 +1,6 @@
import { and, eq } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { NotFoundError, errorResponse } from '@/lib/errors';
import { createPending, listReservations } from '@/lib/services/berth-reservations.service';
import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations';
// URL berthId is authoritative; make body berthId optional (ignored anyway).
const createPendingBodySchema = createPendingSchema
.omit({ berthId: true })
.extend({ berthId: createPendingSchema.shape.berthId.optional() });
async function assertBerthInPort(berthId: string, portId: string): Promise<void> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
}
export const listHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const query = parseQuery(req, listReservationsSchema);
// URL berthId is authoritative; override any client-supplied value.
const result = await listReservations(ctx.portId, { ...query, berthId: params.id! });
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx, params) => {
try {
await assertBerthInPort(params.id!, ctx.portId);
const body = await parseBody(req, createPendingBodySchema);
// URL berthId is authoritative; any body-supplied berthId is ignored.
const reservation = await createPending(
ctx.portId,
{ ...body, berthId: params.id! },
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: reservation }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
export const POST = withAuth(withPermission('reservations', 'create', createHandler));

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
import { updateCompanySchema } from '@/lib/validators/companies';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const company = await getCompanyById(params.id!, ctx.portId);
return NextResponse.json({ data: company });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateCompanySchema);
const updated = await updateCompany(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveCompany(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { updateNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const PATCH = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const companyId = params.id;
const noteId = params.noteId;
if (!companyId) throw new NotFoundError('Company');
if (!noteId) throw new NotFoundError('Note');
const body = await parseBody(req, updateNoteSchema);
const note = await notesService.update(ctx.portId, 'companies', companyId, noteId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'company_note',
entityId: noteId,
metadata: { companyId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('companies', 'edit', async (_req, ctx, params) => {
try {
const companyId = params.id;
const noteId = params.noteId;
if (!companyId) throw new NotFoundError('Company');
if (!noteId) throw new NotFoundError('Note');
await notesService.deleteNote(ctx.portId, 'companies', companyId, noteId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'company_note',
entityId: noteId,
metadata: { companyId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
withPermission('companies', 'view', async (_req, ctx, params) => {
try {
const companyId = params.id;
if (!companyId) throw new NotFoundError('Company');
const notes = await notesService.listForEntity(ctx.portId, 'companies', companyId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const companyId = params.id;
if (!companyId) throw new NotFoundError('Company');
const body = await parseBody(req, createNoteSchema);
const note = await notesService.create(ctx.portId, 'companies', companyId, ctx.userId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'company_note',
entityId: note.id,
metadata: { companyId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,48 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service';
import { updateCompanySchema } from '@/lib/validators/companies';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const company = await getCompanyById(params.id!, ctx.portId);
return NextResponse.json({ data: company });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateCompanySchema);
const updated = await updateCompany(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveCompany(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('companies', 'view', getHandler));
export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler));

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setCompanyTags } from '@/lib/services/companies.service';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
export const PUT = withAuth(
withPermission('companies', 'edit', async (req, ctx, params) => {
try {
const { tagIds } = await parseBody(req, setTagsSchema);
await setCompanyTags(params.id!, ctx.portId, tagIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listCompanies, createCompany } from '@/lib/services/companies.service';
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listCompaniesSchema);
const result = await listCompanies(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createCompanySchema);
const company = await createCompany(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: company }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -1,47 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listCompanies, createCompany } from '@/lib/services/companies.service';
import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listCompaniesSchema);
const result = await listCompanies(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createCompanySchema);
const company = await createCompany(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: company }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('companies', 'view', listHandler));
export const POST = withAuth(withPermission('companies', 'create', createHandler));

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveResidentialClient,
getResidentialClientById,
updateResidentialClient,
} from '@/lib/services/residential.service';
import { updateResidentialClientSchema } from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_clients', 'view', async (req, ctx, params) => {
try {
const client = await getResidentialClientById(params.id!, ctx.portId);
return NextResponse.json({ data: client });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('residential_clients', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateResidentialClientSchema);
const updated = await updateResidentialClient(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('residential_clients', 'delete', async (req, ctx, params) => {
try {
await archiveResidentialClient(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
createResidentialClient,
listResidentialClients,
} from '@/lib/services/residential.service';
import {
createResidentialClientSchema,
listResidentialClientsSchema,
} from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_clients', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listResidentialClientsSchema);
const result = await listResidentialClients(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('residential_clients', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createResidentialClientSchema);
const client = await createResidentialClient(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: client }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveResidentialInterest,
getResidentialInterestById,
updateResidentialInterest,
} from '@/lib/services/residential.service';
import { updateResidentialInterestSchema } from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_interests', 'view', async (req, ctx, params) => {
try {
const interest = await getResidentialInterestById(params.id!, ctx.portId);
return NextResponse.json({ data: interest });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('residential_interests', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, updateResidentialInterestSchema);
const updated = await updateResidentialInterest(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('residential_interests', 'delete', async (req, ctx, params) => {
try {
await archiveResidentialInterest(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
createResidentialInterest,
listResidentialInterests,
} from '@/lib/services/residential.service';
import {
createResidentialInterestSchema,
listResidentialInterestsSchema,
} from '@/lib/validators/residential';
export const GET = withAuth(
withPermission('residential_interests', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listResidentialInterestsSchema);
const result = await listResidentialInterests(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('residential_interests', 'create', async (req, ctx) => {
try {
const body = await parseBody(req, createResidentialInterestSchema);
const interest = await createResidentialInterest(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: interest }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
import { updateYachtSchema } from '@/lib/validators/yachts';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const yacht = await getYachtById(params.id!, ctx.portId);
return NextResponse.json({ data: yacht });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateYachtSchema);
const updated = await updateYacht(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveYacht(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { updateNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const PATCH = withAuth(
withPermission('yachts', 'edit', async (req, ctx, params) => {
try {
const yachtId = params.id;
const noteId = params.noteId;
if (!yachtId) throw new NotFoundError('Yacht');
if (!noteId) throw new NotFoundError('Note');
const body = await parseBody(req, updateNoteSchema);
const note = await notesService.update(ctx.portId, 'yachts', yachtId, noteId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'yacht_note',
entityId: noteId,
metadata: { yachtId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note });
} catch (error) {
return errorResponse(error);
}
}),
);
export const DELETE = withAuth(
withPermission('yachts', 'edit', async (_req, ctx, params) => {
try {
const yachtId = params.id;
const noteId = params.noteId;
if (!yachtId) throw new NotFoundError('Yacht');
if (!noteId) throw new NotFoundError('Note');
await notesService.deleteNote(ctx.portId, 'yachts', yachtId, noteId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'yacht_note',
entityId: noteId,
metadata: { yachtId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
withPermission('yachts', 'view', async (_req, ctx, params) => {
try {
const yachtId = params.id;
if (!yachtId) throw new NotFoundError('Yacht');
const notes = await notesService.listForEntity(ctx.portId, 'yachts', yachtId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('yachts', 'edit', async (req, ctx, params) => {
try {
const yachtId = params.id;
if (!yachtId) throw new NotFoundError('Yacht');
const body = await parseBody(req, createNoteSchema);
const note = await notesService.create(ctx.portId, 'yachts', yachtId, ctx.userId, body);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'yacht_note',
entityId: note.id,
metadata: { yachtId },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: note }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,48 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service';
import { updateYachtSchema } from '@/lib/validators/yachts';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const yacht = await getYachtById(params.id!, ctx.portId);
return NextResponse.json({ data: yacht });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateYachtSchema);
const updated = await updateYacht(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveYacht(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler));

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setYachtTags } from '@/lib/services/yachts.service';
const setTagsSchema = z.object({
tagIds: z.array(z.string()),
});
export const PUT = withAuth(
withPermission('yachts', 'edit', async (req, ctx, params) => {
try {
const { tagIds } = await parseBody(req, setTagsSchema);
await setYachtTags(params.id!, ctx.portId, tagIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ success: true });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listYachts, createYacht } from '@/lib/services/yachts.service';
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listYachtsSchema);
const result = await listYachts(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createYachtSchema);
const yacht = await createYacht(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: yacht }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -1,47 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
import { parseQuery, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listYachts, createYacht } from '@/lib/services/yachts.service';
import { listYachtsSchema, createYachtSchema } from '@/lib/validators/yachts';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const query = parseQuery(req, listYachtsSchema);
const result = await listYachts(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createYachtSchema);
const yacht = await createYacht(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: yacht }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('yachts', 'view', listHandler));
export const POST = withAuth(withPermission('yachts', 'create', createHandler));

View File

@@ -0,0 +1,38 @@
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { userPortRoles, userProfiles } from '@/lib/db/schema/users';
/**
* Plain `/dashboard` lands users into their default port's dashboard. Used
* by post-login redirects and any code that doesn't yet know the active
* port slug.
*/
export default async function DashboardRedirectPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) redirect('/login');
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
});
let slug: string | undefined;
if (profile?.isSuperAdmin) {
const first = await db.query.ports.findFirst({ orderBy: portsTable.name });
slug = first?.slug;
} else {
const role = await db.query.userPortRoles.findFirst({
where: eq(userPortRoles.userId, session.user.id),
with: { port: true },
});
slug = role?.port.slug;
}
if (!slug) redirect('/login?error=no-port-access');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(`/${slug}/dashboard` as any);
}