diff --git a/src/app/(dashboard)/[portSlug]/admin/audit/page.tsx b/src/app/(dashboard)/[portSlug]/admin/audit/page.tsx
index 4f0a82f..731a3fd 100644
--- a/src/app/(dashboard)/[portSlug]/admin/audit/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/audit/page.tsx
@@ -1,16 +1,5 @@
+import { AuditLogList } from '@/components/admin/audit/audit-log-list';
+
export default function AuditLogPage() {
- return (
-
-
-
Audit Log
-
Review system activity and changes
-
-
-
Coming in Layer 2
-
- This feature will be implemented in the next phase.
-
-
-
- );
+ return ;
}
diff --git a/src/app/(dashboard)/[portSlug]/settings/page.tsx b/src/app/(dashboard)/[portSlug]/settings/page.tsx
index 36c1254..360c07f 100644
--- a/src/app/(dashboard)/[portSlug]/settings/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/settings/page.tsx
@@ -1,16 +1,5 @@
+import { UserSettings } from '@/components/settings/user-settings';
+
export default function SettingsPage() {
- return (
-
-
-
Settings
-
Manage your account and port preferences
-
-
-
Coming in Layer 2
-
- This feature will be implemented in the next phase.
-
-
-
- );
+ return ;
}
diff --git a/src/app/api/v1/admin/audit/route.ts b/src/app/api/v1/admin/audit/route.ts
new file mode 100644
index 0000000..f9d34d6
--- /dev/null
+++ b/src/app/api/v1/admin/audit/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseQuery } from '@/lib/api/route-helpers';
+import { listAuditLogs } from '@/lib/services/audit.service';
+import { errorResponse } from '@/lib/errors';
+
+const auditQuerySchema = z.object({
+ page: z.coerce.number().int().min(1).default(1),
+ limit: z.coerce.number().int().min(1).max(100).default(50),
+ entityType: z.string().optional(),
+ action: z.string().optional(),
+ userId: z.string().optional(),
+ entityId: z.string().optional(),
+ dateFrom: z.string().optional(),
+ dateTo: z.string().optional(),
+ search: z.string().optional(),
+});
+
+export const GET = withAuth(
+ withPermission('admin', 'view_audit_log', async (req, ctx) => {
+ try {
+ const query = parseQuery(req, auditQuerySchema);
+ const result = await listAuditLogs(ctx.portId, query);
+ return NextResponse.json(result);
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/berths/[id]/route.ts b/src/app/api/v1/berths/[id]/route.ts
index 2f78c8f..5df2ee9 100644
--- a/src/app/api/v1/berths/[id]/route.ts
+++ b/src/app/api/v1/berths/[id]/route.ts
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateBerthSchema } from '@/lib/validators/berths';
-import { getBerthById, updateBerth } from '@/lib/services/berths.service';
+import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]
@@ -18,7 +18,7 @@ export const GET = withAuth(
}),
);
-// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
+// PATCH /api/v1/berths/[id]
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
@@ -35,3 +35,20 @@ export const PATCH = withAuth(
}
}),
);
+
+// DELETE /api/v1/berths/[id]
+export const DELETE = withAuth(
+ withPermission('berths', 'edit', async (_req, ctx, params) => {
+ try {
+ await deleteBerth(params.id!, ctx.portId, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/berths/route.ts b/src/app/api/v1/berths/route.ts
index 73a25fd..9df90e0 100644
--- a/src/app/api/v1/berths/route.ts
+++ b/src/app/api/v1/berths/route.ts
@@ -1,12 +1,11 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
-import { parseQuery } from '@/lib/api/route-helpers';
-import { listBerthsSchema } from '@/lib/validators/berths';
-import { listBerths } from '@/lib/services/berths.service';
+import { parseBody, parseQuery } from '@/lib/api/route-helpers';
+import { listBerthsSchema, createBerthSchema } from '@/lib/validators/berths';
+import { listBerths, createBerth } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
-// GET /api/v1/berths — list berths for the current port (no POST — import-only)
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {
@@ -34,3 +33,20 @@ export const GET = withAuth(
}
}),
);
+
+export const POST = withAuth(
+ withPermission('berths', 'edit', async (req, ctx) => {
+ try {
+ const body = await parseBody(req, createBerthSchema);
+ const data = await createBerth(ctx.portId, body, {
+ userId: ctx.userId,
+ portId: ctx.portId,
+ ipAddress: ctx.ipAddress,
+ userAgent: ctx.userAgent,
+ });
+ return NextResponse.json({ data }, { status: 201 });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/clients/duplicates/route.ts b/src/app/api/v1/clients/duplicates/route.ts
new file mode 100644
index 0000000..23e5ad5
--- /dev/null
+++ b/src/app/api/v1/clients/duplicates/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server';
+import { z } from 'zod';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { parseQuery } from '@/lib/api/route-helpers';
+import { findDuplicates } from '@/lib/services/clients.service';
+import { errorResponse } from '@/lib/errors';
+
+const duplicateQuerySchema = z.object({
+ name: z.string().min(1),
+});
+
+export const GET = withAuth(
+ withPermission('clients', 'view', async (req, ctx) => {
+ try {
+ const { name } = parseQuery(req, duplicateQuerySchema);
+ const data = await findDuplicates(ctx.portId, name);
+ return NextResponse.json({ data });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts
index 2a5be16..ad08d1b 100644
--- a/src/app/api/v1/me/route.ts
+++ b/src/app/api/v1/me/route.ts
@@ -1,5 +1,26 @@
import { NextResponse } from 'next/server';
+import { eq } from 'drizzle-orm';
+
import { withAuth, type AuthContext } from '@/lib/api/helpers';
+import { parseBody } from '@/lib/api/route-helpers';
+import { db } from '@/lib/db';
+import { userProfiles } from '@/lib/db/schema';
+import { errorResponse } from '@/lib/errors';
+import { z } from 'zod';
+
+const updateProfileSchema = z.object({
+ displayName: z.string().min(1).max(200).optional(),
+ phone: z.string().nullable().optional(),
+ avatarUrl: z.string().url().nullable().optional(),
+ preferences: z
+ .object({
+ dark_mode: z.boolean().optional(),
+ locale: z.string().optional(),
+ timezone: z.string().optional(),
+ })
+ .passthrough()
+ .optional(),
+});
export const GET = withAuth(async (_req, ctx: AuthContext) => {
return NextResponse.json({
@@ -13,3 +34,45 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
},
});
});
+
+export const PATCH = withAuth(async (req, ctx: AuthContext) => {
+ try {
+ const body = await parseBody(req, updateProfileSchema);
+
+ const profile = await db.query.userProfiles.findFirst({
+ where: eq(userProfiles.userId, ctx.userId),
+ });
+ if (!profile) {
+ return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
+ }
+
+ const updates: Record = { updatedAt: new Date() };
+ if (body.displayName !== undefined) updates.displayName = body.displayName;
+ if (body.phone !== undefined) updates.phone = body.phone;
+ if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
+ if (body.preferences !== undefined) {
+ updates.preferences = {
+ ...((profile.preferences as Record) ?? {}),
+ ...body.preferences,
+ };
+ }
+
+ const [updated] = await db
+ .update(userProfiles)
+ .set(updates)
+ .where(eq(userProfiles.userId, ctx.userId))
+ .returning();
+
+ return NextResponse.json({
+ data: {
+ userId: updated!.userId,
+ displayName: updated!.displayName,
+ phone: updated!.phone,
+ avatarUrl: updated!.avatarUrl,
+ preferences: updated!.preferences,
+ },
+ });
+ } catch (error) {
+ return errorResponse(error);
+ }
+});
diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx
new file mode 100644
index 0000000..d4daaff
--- /dev/null
+++ b/src/components/admin/audit/audit-log-list.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { type ColumnDef } from '@tanstack/react-table';
+import { formatDistanceToNow } from 'date-fns';
+import { Search } from 'lucide-react';
+
+import { DataTable } from '@/components/shared/data-table';
+import { PageHeader } from '@/components/shared/page-header';
+import { Badge } from '@/components/ui/badge';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { apiFetch } from '@/lib/api/client';
+
+interface AuditEntry {
+ id: string;
+ userId: string | null;
+ action: string;
+ entityType: string;
+ entityId: string;
+ fieldChanged: string | null;
+ oldValue: Record | null;
+ newValue: Record | null;
+ metadata: Record | null;
+ ipAddress: string | null;
+ createdAt: string;
+}
+
+const ACTION_COLORS: Record = {
+ create: 'bg-green-600',
+ update: 'bg-blue-500',
+ delete: 'bg-red-600',
+ archive: 'bg-orange-500',
+ restore: 'bg-teal-500',
+ login: 'bg-gray-500',
+ permission_denied: 'bg-red-800',
+};
+
+const ENTITY_TYPES = [
+ 'client',
+ 'interest',
+ 'berth',
+ 'document',
+ 'expense',
+ 'invoice',
+ 'reminder',
+ 'user',
+ 'role',
+ 'port',
+ 'setting',
+ 'tag',
+ 'webhook',
+];
+
+export function AuditLogList() {
+ const [entries, setEntries] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [entityTypeFilter, setEntityTypeFilter] = useState('all');
+ const [actionFilter, setActionFilter] = useState('all');
+ const [search, setSearch] = useState('');
+
+ const fetchLogs = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams({
+ page: String(page),
+ limit: '50',
+ });
+ if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
+ if (actionFilter !== 'all') params.set('action', actionFilter);
+ if (search) params.set('search', search);
+
+ const res = await apiFetch<{
+ data: AuditEntry[];
+ pagination: { total: number };
+ }>(`/api/v1/admin/audit?${params}`);
+ setEntries(res.data);
+ setTotal(res.pagination.total);
+ } finally {
+ setLoading(false);
+ }
+ }, [page, entityTypeFilter, actionFilter, search]);
+
+ useEffect(() => {
+ void fetchLogs();
+ }, [fetchLogs]);
+
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: 'createdAt',
+ header: 'Time',
+ cell: ({ row }) => (
+
+
{new Date(row.original.createdAt).toLocaleString()}
+
+ {formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
+
+
+ ),
+ size: 180,
+ },
+ {
+ accessorKey: 'action',
+ header: 'Action',
+ cell: ({ row }) => (
+
+ {row.original.action}
+
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: 'entityType',
+ header: 'Entity',
+ cell: ({ row }) => (
+
+ {row.original.entityType}
+
+ {row.original.entityId.slice(0, 8)}...
+
+
+ ),
+ },
+ {
+ id: 'changes',
+ header: 'Changes',
+ cell: ({ row }) => {
+ const { newValue, fieldChanged } = row.original;
+ if (fieldChanged) return {fieldChanged};
+ if (newValue) {
+ const keys = Object.keys(newValue);
+ return (
+
+ {keys.slice(0, 3).join(', ')}
+ {keys.length > 3 ? ` +${keys.length - 3} more` : ''}
+
+ );
+ }
+ return —;
+ },
+ },
+ {
+ accessorKey: 'userId',
+ header: 'User',
+ cell: ({ row }) => (
+
+ {row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
+
+ ),
+ size: 100,
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+ {
+ setSearch(e.target.value);
+ setPage(1);
+ }}
+ />
+
+
+
+
+
+
row.id}
+ emptyState={
+
+
No audit log entries found.
+
+ }
+ />
+
+ {total > 50 && (
+
+
+
+ Page {page} of {Math.ceil(total / 50)}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx
new file mode 100644
index 0000000..27e3522
--- /dev/null
+++ b/src/components/settings/user-settings.tsx
@@ -0,0 +1,174 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Save } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { PageHeader } from '@/components/shared/page-header';
+import { apiFetch } from '@/lib/api/client';
+
+interface NotificationPrefs {
+ reminder_due: boolean;
+ reminder_overdue: boolean;
+ eoi_signed: boolean;
+ eoi_completed: boolean;
+ invoice_overdue: boolean;
+ duplicate_alert: boolean;
+ [key: string]: boolean;
+}
+
+export function UserSettings() {
+ const [notifPrefs, setNotifPrefs] = useState(null);
+ const [displayName, setDisplayName] = useState('');
+ const [phone, setPhone] = useState('');
+ const [timezone, setTimezone] = useState('');
+ const [saving, setSaving] = useState(null);
+ const [message, setMessage] = useState(null);
+
+ useEffect(() => {
+ void loadProfile();
+ void loadNotificationPrefs();
+ }, []);
+
+ async function loadProfile() {
+ const res = await apiFetch<{ data: { user?: { name: string } } }>('/api/v1/me', {
+ method: 'GET',
+ });
+ setDisplayName(res.data.user?.name ?? '');
+ }
+
+ async function loadNotificationPrefs() {
+ try {
+ const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
+ setNotifPrefs(res.data);
+ } catch {
+ // Preferences may not exist yet
+ setNotifPrefs({
+ reminder_due: true,
+ reminder_overdue: true,
+ eoi_signed: true,
+ eoi_completed: true,
+ invoice_overdue: true,
+ duplicate_alert: true,
+ });
+ }
+ }
+
+ async function saveProfile() {
+ setSaving('profile');
+ setMessage(null);
+ try {
+ await apiFetch('/api/v1/me', {
+ method: 'PATCH',
+ body: {
+ displayName: displayName || undefined,
+ phone: phone || null,
+ preferences: { timezone: timezone || undefined },
+ },
+ });
+ setMessage('Profile saved');
+ } catch (err: unknown) {
+ setMessage(err instanceof Error ? err.message : 'Failed to save');
+ } finally {
+ setSaving(null);
+ }
+ }
+
+ async function toggleNotifPref(key: string, value: boolean) {
+ setSaving(key);
+ try {
+ await apiFetch('/api/v1/notifications/preferences', {
+ method: 'PATCH',
+ body: { [key]: value },
+ });
+ setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
+ } finally {
+ setSaving(null);
+ }
+ }
+
+ const NOTIF_LABELS: Record = {
+ reminder_due: 'Reminder due',
+ reminder_overdue: 'Reminder overdue',
+ eoi_signed: 'EOI signed by a party',
+ eoi_completed: 'EOI fully completed',
+ invoice_overdue: 'Invoice overdue',
+ duplicate_alert: 'Duplicate client detected',
+ };
+
+ return (
+
+ );
+}
diff --git a/src/lib/services/audit.service.ts b/src/lib/services/audit.service.ts
new file mode 100644
index 0000000..df4175a
--- /dev/null
+++ b/src/lib/services/audit.service.ts
@@ -0,0 +1,57 @@
+import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import { auditLogs } from '@/lib/db/schema';
+
+interface AuditListQuery {
+ page: number;
+ limit: number;
+ entityType?: string;
+ action?: string;
+ userId?: string;
+ entityId?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ search?: string;
+}
+
+export async function listAuditLogs(portId: string, query: AuditListQuery) {
+ const conditions = [eq(auditLogs.portId, portId)];
+
+ if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
+ if (query.action) conditions.push(eq(auditLogs.action, query.action));
+ if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
+ if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
+ if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
+ if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
+ if (query.search) {
+ conditions.push(
+ sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
+ );
+ }
+
+ const offset = (query.page - 1) * query.limit;
+
+ const [data, countResult] = await Promise.all([
+ db
+ .select()
+ .from(auditLogs)
+ .where(and(...conditions))
+ .orderBy(desc(auditLogs.createdAt))
+ .limit(query.limit)
+ .offset(offset),
+ db
+ .select({ count: sql`count(*)` })
+ .from(auditLogs)
+ .where(and(...conditions)),
+ ]);
+
+ return {
+ data,
+ pagination: {
+ page: query.page,
+ limit: query.limit,
+ total: Number(countResult[0]?.count ?? 0),
+ },
+ };
+}
diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts
index a91ff8f..d21e01d 100644
--- a/src/lib/services/berths.service.ts
+++ b/src/lib/services/berths.service.ts
@@ -1,19 +1,16 @@
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
-import {
- berths,
- berthTags,
- berthWaitingList,
- berthMaintenanceLog,
-} from '@/lib/db/schema/berths';
+import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
+import { ConflictError } from '@/lib/errors';
import type {
+ CreateBerthInput,
UpdateBerthInput,
UpdateBerthStatusInput,
ListBerthsQuery,
@@ -71,12 +68,18 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
const sortColumn = (() => {
switch (query.sort) {
- case 'mooringNumber': return berths.mooringNumber;
- case 'area': return berths.area;
- case 'price': return berths.price;
- case 'status': return berths.status;
- case 'lengthM': return berths.lengthM;
- default: return berths.updatedAt;
+ case 'mooringNumber':
+ return berths.mooringNumber;
+ case 'area':
+ return berths.area;
+ case 'price':
+ return berths.price;
+ case 'status':
+ return berths.status;
+ case 'lengthM':
+ return berths.lengthM;
+ default:
+ return berths.updatedAt;
}
})();
@@ -161,7 +164,10 @@ export async function updateBerth(
});
if (!existing) throw new NotFoundError('Berth');
- const { changed, diff } = diffEntity(existing as Record, data as Record);
+ const { changed, diff } = diffEntity(
+ existing as Record,
+ data as Record,
+ );
if (!changed) return existing;
@@ -288,12 +294,7 @@ export async function updateBerthStatus(
// ─── Set Tags ─────────────────────────────────────────────────────────────────
-export async function setBerthTags(
- id: string,
- portId: string,
- tagIds: string[],
- meta: AuditMeta,
-) {
+export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, id), eq(berths.portId, portId)),
});
@@ -454,6 +455,90 @@ export async function updateWaitingList(
return data.entries;
}
+// ─── Create ──────────────────────────────────────────────────────────────────
+
+export async function createBerth(portId: string, data: CreateBerthInput, meta: AuditMeta) {
+ // Check mooring number uniqueness within port
+ const existing = await db.query.berths.findFirst({
+ where: and(eq(berths.portId, portId), eq(berths.mooringNumber, data.mooringNumber)),
+ });
+ if (existing) {
+ throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
+ }
+
+ const [berth] = await db
+ .insert(berths)
+ .values({
+ portId,
+ mooringNumber: data.mooringNumber,
+ area: data.area,
+ status: data.status ?? 'available',
+ lengthFt: data.lengthFt?.toString(),
+ lengthM: data.lengthM?.toString(),
+ widthFt: data.widthFt?.toString(),
+ widthM: data.widthM?.toString(),
+ draftFt: data.draftFt?.toString(),
+ draftM: data.draftM?.toString(),
+ price: data.price?.toString(),
+ priceCurrency: data.priceCurrency ?? 'USD',
+ tenureType: data.tenureType ?? 'permanent',
+ mooringType: data.mooringType,
+ powerCapacity: data.powerCapacity,
+ voltage: data.voltage,
+ access: data.access,
+ bowFacing: data.bowFacing,
+ sidePontoon: data.sidePontoon,
+ })
+ .returning();
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId,
+ action: 'create',
+ entityType: 'berth',
+ entityId: berth!.id,
+ newValue: { mooringNumber: berth!.mooringNumber, area: berth!.area },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${portId}`, 'system:alert', {
+ alertType: 'berth:created',
+ message: `Berth "${berth!.mooringNumber}" created`,
+ severity: 'info',
+ });
+
+ return berth!;
+}
+
+// ─── Delete ─────────────────────────────────────────────────────────────────
+
+export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
+ const berth = await db.query.berths.findFirst({
+ where: and(eq(berths.id, id), eq(berths.portId, portId)),
+ });
+ if (!berth) throw new NotFoundError('Berth');
+
+ await db.delete(berths).where(and(eq(berths.id, id), eq(berths.portId, portId)));
+
+ void createAuditLog({
+ userId: meta.userId,
+ portId,
+ action: 'delete',
+ entityType: 'berth',
+ entityId: id,
+ oldValue: { mooringNumber: berth.mooringNumber, area: berth.area },
+ ipAddress: meta.ipAddress,
+ userAgent: meta.userAgent,
+ });
+
+ emitToRoom(`port:${portId}`, 'system:alert', {
+ alertType: 'berth:deleted',
+ message: `Berth "${berth.mooringNumber}" deleted`,
+ severity: 'info',
+ });
+}
+
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {
diff --git a/src/lib/validators/berths.ts b/src/lib/validators/berths.ts
index 1c65408..e4f3f7b 100644
--- a/src/lib/validators/berths.ts
+++ b/src/lib/validators/berths.ts
@@ -2,6 +2,31 @@ import { z } from 'zod';
import { BERTH_STATUSES } from '@/lib/constants';
import { baseListQuerySchema } from '@/lib/api/route-helpers';
+// ─── Create Berth ────────────────────────────────────────────────────────────
+
+export const createBerthSchema = z.object({
+ mooringNumber: z.string().min(1),
+ area: z.string().min(1),
+ lengthFt: z.coerce.number().optional(),
+ lengthM: z.coerce.number().optional(),
+ widthFt: z.coerce.number().optional(),
+ widthM: z.coerce.number().optional(),
+ draftFt: z.coerce.number().optional(),
+ draftM: z.coerce.number().optional(),
+ price: z.coerce.number().optional(),
+ priceCurrency: z.string().optional(),
+ status: z.enum(BERTH_STATUSES).default('available'),
+ tenureType: z.enum(['permanent', 'fixed_term']).optional(),
+ mooringType: z.string().optional(),
+ powerCapacity: z.string().optional(),
+ voltage: z.string().optional(),
+ access: z.string().optional(),
+ bowFacing: z.string().optional(),
+ sidePontoon: z.string().optional(),
+});
+
+export type CreateBerthInput = z.infer;
+
// ─── Update Berth ─────────────────────────────────────────────────────────────
export const updateBerthSchema = z.object({