From 8df8ded46cb5b101dc4bc6d466a6c55f52cff8dc Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 8 Apr 2026 19:45:56 -0400 Subject: [PATCH] Add user settings, audit log, berth CRUD, and missing endpoints - PATCH /api/v1/me: self-service profile update (name, phone, timezone) - User settings page with profile editor + notification preferences - Audit log API with filtering (entity, action, user, date range) - Audit log page with search, entity type, and action filters - Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id] - Client duplicates endpoint: GET /api/v1/clients/duplicates?name= - Replace settings and audit stub pages with real implementations Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[portSlug]/admin/audit/page.tsx | 17 +- .../(dashboard)/[portSlug]/settings/page.tsx | 17 +- src/app/api/v1/admin/audit/route.ts | 31 +++ src/app/api/v1/berths/[id]/route.ts | 21 +- src/app/api/v1/berths/route.ts | 24 +- src/app/api/v1/clients/duplicates/route.ts | 23 ++ src/app/api/v1/me/route.ts | 63 +++++ src/components/admin/audit/audit-log-list.tsx | 257 ++++++++++++++++++ src/components/settings/user-settings.tsx | 174 ++++++++++++ src/lib/services/audit.service.ts | 57 ++++ src/lib/services/berths.service.ts | 123 +++++++-- src/lib/validators/berths.ts | 25 ++ 12 files changed, 779 insertions(+), 53 deletions(-) create mode 100644 src/app/api/v1/admin/audit/route.ts create mode 100644 src/app/api/v1/clients/duplicates/route.ts create mode 100644 src/components/admin/audit/audit-log-list.tsx create mode 100644 src/components/settings/user-settings.tsx create mode 100644 src/lib/services/audit.service.ts 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 ( +
+ + +
+ + + Profile + Update your display name and contact info + + +
+ + setDisplayName(e.target.value)} + placeholder="Your name" + /> +
+
+ + setPhone(e.target.value)} + placeholder="+1 555-0123" + /> +
+
+ + setTimezone(e.target.value)} + placeholder="America/Anguilla" + /> +
+
+ + {message && {message}} +
+
+
+ + + + Notifications + Choose which notifications you receive + + + {notifPrefs && + Object.entries(NOTIF_LABELS).map(([key, label]) => ( +
+ + toggleNotifPref(key, checked)} + /> +
+ ))} +
+
+
+
+ ); +} 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({