feat(platform): residential module + admin UI + reliability fixes
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:
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
5
src/app/(dashboard)/[portSlug]/dashboard/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardShell />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ResidentialClientsList } from '@/components/residential/residential-clients-list';
|
||||
|
||||
export default function ResidentialClientsPage() {
|
||||
return <ResidentialClientsList />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ResidentialInterestsList } from '@/components/residential/residential-interests-list';
|
||||
|
||||
export default function ResidentialInterestsPage() {
|
||||
return <ResidentialInterestsList />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/app/api/auth/set-password/route.ts
Normal file
37
src/app/api/auth/set-password/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
176
src/app/api/public/residential-inquiries/route.ts
Normal file
176
src/app/api/public/residential-inquiries/route.ts
Normal 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);
|
||||
}
|
||||
58
src/app/api/v1/admin/form-templates/[id]/route.ts
Normal file
58
src/app/api/v1/admin/form-templates/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
35
src/app/api/v1/admin/form-templates/route.ts
Normal file
35
src/app/api/v1/admin/form-templates/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
65
src/app/api/v1/berths/[id]/reservations/handlers.ts
Normal file
65
src/app/api/v1/berths/[id]/reservations/handlers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
45
src/app/api/v1/companies/[id]/handlers.ts
Normal file
45
src/app/api/v1/companies/[id]/handlers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
63
src/app/api/v1/companies/[id]/notes/[noteId]/route.ts
Normal file
63
src/app/api/v1/companies/[id]/notes/[noteId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
47
src/app/api/v1/companies/[id]/notes/route.ts
Normal file
47
src/app/api/v1/companies/[id]/notes/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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));
|
||||
|
||||
28
src/app/api/v1/companies/[id]/tags/route.ts
Normal file
28
src/app/api/v1/companies/[id]/tags/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
44
src/app/api/v1/companies/handlers.ts
Normal file
44
src/app/api/v1/companies/handlers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
55
src/app/api/v1/residential/clients/[id]/route.ts
Normal file
55
src/app/api/v1/residential/clients/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
54
src/app/api/v1/residential/clients/route.ts
Normal file
54
src/app/api/v1/residential/clients/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
55
src/app/api/v1/residential/interests/[id]/route.ts
Normal file
55
src/app/api/v1/residential/interests/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
54
src/app/api/v1/residential/interests/route.ts
Normal file
54
src/app/api/v1/residential/interests/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
45
src/app/api/v1/yachts/[id]/handlers.ts
Normal file
45
src/app/api/v1/yachts/[id]/handlers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
63
src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts
Normal file
63
src/app/api/v1/yachts/[id]/notes/[noteId]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
47
src/app/api/v1/yachts/[id]/notes/route.ts
Normal file
47
src/app/api/v1/yachts/[id]/notes/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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));
|
||||
|
||||
28
src/app/api/v1/yachts/[id]/tags/route.ts
Normal file
28
src/app/api/v1/yachts/[id]/tags/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
44
src/app/api/v1/yachts/handlers.ts
Normal file
44
src/app/api/v1/yachts/handlers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
38
src/app/dashboard/page.tsx
Normal file
38
src/app/dashboard/page.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user