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:
57
src/lib/services/audit.service.ts
Normal file
57
src/lib/services/audit.service.ts
Normal file
@@ -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<number>`count(*)` })
|
||||
.from(auditLogs)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof createBerthSchema>;
|
||||
|
||||
// ─── Update Berth ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const updateBerthSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user