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) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 19:45:56 -04:00
parent 4fdd9e3207
commit 8df8ded46c
12 changed files with 779 additions and 53 deletions

View File

@@ -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<string, unknown>, data as Record<string, unknown>);
const { changed, diff } = diffEntity(
existing as Record<string, unknown>,
data as Record<string, unknown>,
);
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) {