Drizzle SQL template was running `\d+$` through JS string-literal escape rules, eating the backslash and matching every character class \d alias instead of digits. Berths sorted lexically (A1, A10, A11, A2, …) instead of by trailing number. Switch to `[0-9]+$` POSIX form which survives the escape pass intact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
569 lines
18 KiB
TypeScript
569 lines
18 KiB
TypeScript
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
|
import { clients } from '@/lib/db/schema/clients';
|
|
import { tags } from '@/lib/db/schema/system';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
|
import { ConflictError } from '@/lib/errors';
|
|
import type {
|
|
CreateBerthInput,
|
|
UpdateBerthInput,
|
|
UpdateBerthStatusInput,
|
|
ListBerthsQuery,
|
|
AddMaintenanceLogInput,
|
|
UpdateWaitingListInput,
|
|
} from '@/lib/validators/berths';
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function listBerths(portId: string, query: ListBerthsQuery) {
|
|
const filters = [];
|
|
|
|
if (query.status) {
|
|
filters.push(eq(berths.status, query.status));
|
|
}
|
|
if (query.area) {
|
|
filters.push(eq(berths.area, query.area));
|
|
}
|
|
if (query.minLength !== undefined) {
|
|
filters.push(gte(berths.lengthM, String(query.minLength)));
|
|
}
|
|
if (query.maxLength !== undefined) {
|
|
filters.push(lte(berths.lengthM, String(query.maxLength)));
|
|
}
|
|
if (query.minPrice !== undefined) {
|
|
filters.push(gte(berths.price, String(query.minPrice)));
|
|
}
|
|
if (query.maxPrice !== undefined) {
|
|
filters.push(lte(berths.price, String(query.maxPrice)));
|
|
}
|
|
if (query.tenureType) {
|
|
filters.push(eq(berths.tenureType, query.tenureType));
|
|
}
|
|
|
|
// Tag filter: join against berthTags
|
|
if (query.tagIds && query.tagIds.length > 0) {
|
|
const tagIds = query.tagIds;
|
|
const berthsWithTags = await db
|
|
.selectDistinct({ berthId: berthTags.berthId })
|
|
.from(berthTags)
|
|
.where(inArray(berthTags.tagId, tagIds));
|
|
const matchingIds = berthsWithTags.map((r) => r.berthId);
|
|
if (matchingIds.length === 0) {
|
|
return { data: [], total: 0 };
|
|
}
|
|
filters.push(inArray(berths.id, matchingIds));
|
|
}
|
|
|
|
// Default ordering is natural alphanumeric on mooring number
|
|
// (A1, A2, A10, B1...) — Postgres' default lexicographic sort
|
|
// would put A10 before A2, which is the wrong story for a marina
|
|
// map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
|
|
// splits are safe.
|
|
const NATURAL_MOORING_SORT = [
|
|
sql`regexp_replace(${berths.mooringNumber}, '[0-9]+$', '') ASC`,
|
|
sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
|
|
];
|
|
|
|
const sortColumn = (() => {
|
|
switch (query.sort) {
|
|
case 'mooringNumber':
|
|
// Honoured via customOrderBy below — caller asked for mooring
|
|
// sort explicitly, give them the natural order.
|
|
return null;
|
|
case 'area':
|
|
return berths.area;
|
|
case 'price':
|
|
return berths.price;
|
|
case 'status':
|
|
return berths.status;
|
|
case 'lengthM':
|
|
return berths.lengthM;
|
|
default:
|
|
// No sort requested → natural mooring order is the friendliest
|
|
// default for the berth grid (groups by pontoon letter).
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const result = await buildListQuery({
|
|
table: berths,
|
|
portIdColumn: berths.portId,
|
|
portId,
|
|
idColumn: berths.id,
|
|
updatedAtColumn: berths.updatedAt,
|
|
filters,
|
|
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
|
|
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
|
|
page: query.page,
|
|
pageSize: query.limit,
|
|
searchColumns: [berths.mooringNumber, berths.area],
|
|
searchTerm: query.search,
|
|
// No archivedAt column on berths
|
|
includeArchived: true,
|
|
});
|
|
|
|
// Attach tags for list items
|
|
const berthIds = (result.data as Array<{ id: string }>).map((b) => b.id);
|
|
const tagsByBerthId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
|
|
|
|
if (berthIds.length > 0) {
|
|
const tagRows = await db
|
|
.select({
|
|
berthId: berthTags.berthId,
|
|
id: tags.id,
|
|
name: tags.name,
|
|
color: tags.color,
|
|
})
|
|
.from(berthTags)
|
|
.innerJoin(tags, eq(berthTags.tagId, tags.id))
|
|
.where(inArray(berthTags.berthId, berthIds));
|
|
|
|
for (const row of tagRows) {
|
|
if (!tagsByBerthId[row.berthId]) tagsByBerthId[row.berthId] = [];
|
|
tagsByBerthId[row.berthId]!.push({ id: row.id, name: row.name, color: row.color });
|
|
}
|
|
}
|
|
|
|
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
|
|
...b,
|
|
tags: tagsByBerthId[b.id as string] ?? [],
|
|
}));
|
|
|
|
return { data, total: result.total };
|
|
}
|
|
|
|
// ─── Get By ID ────────────────────────────────────────────────────────────────
|
|
|
|
export async function getBerthById(id: string, portId: string) {
|
|
const berth = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
with: {
|
|
mapData: true,
|
|
},
|
|
});
|
|
|
|
if (!berth) throw new NotFoundError('Berth');
|
|
|
|
// Fetch tags
|
|
const tagRows = await db
|
|
.select({ id: tags.id, name: tags.name, color: tags.color })
|
|
.from(berthTags)
|
|
.innerJoin(tags, eq(berthTags.tagId, tags.id))
|
|
.where(eq(berthTags.berthId, id));
|
|
|
|
return { ...berth, tags: tagRows };
|
|
}
|
|
|
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function updateBerth(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateBerthInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
const { changed, diff } = diffEntity(
|
|
existing as Record<string, unknown>,
|
|
data as Record<string, unknown>,
|
|
);
|
|
|
|
if (!changed) return existing;
|
|
|
|
// Drizzle numeric columns expect string | null - coerce numbers to strings
|
|
const n = (v: number | undefined) => (v !== undefined ? String(v) : undefined);
|
|
|
|
const [updated] = await db
|
|
.update(berths)
|
|
.set({
|
|
area: data.area,
|
|
lengthFt: n(data.lengthFt),
|
|
lengthM: n(data.lengthM),
|
|
widthFt: n(data.widthFt),
|
|
widthM: n(data.widthM),
|
|
draftFt: n(data.draftFt),
|
|
draftM: n(data.draftM),
|
|
widthIsMinimum: data.widthIsMinimum,
|
|
nominalBoatSize: n(data.nominalBoatSize),
|
|
nominalBoatSizeM: n(data.nominalBoatSizeM),
|
|
waterDepth: n(data.waterDepth),
|
|
waterDepthM: n(data.waterDepthM),
|
|
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
|
sidePontoon: data.sidePontoon,
|
|
powerCapacity: n(data.powerCapacity),
|
|
voltage: n(data.voltage),
|
|
mooringType: data.mooringType,
|
|
cleatType: data.cleatType,
|
|
cleatCapacity: data.cleatCapacity,
|
|
bollardType: data.bollardType,
|
|
bollardCapacity: data.bollardCapacity,
|
|
access: data.access,
|
|
price: n(data.price),
|
|
priceCurrency: data.priceCurrency,
|
|
bowFacing: data.bowFacing,
|
|
berthApproved: data.berthApproved,
|
|
tenureType: data.tenureType,
|
|
tenureYears: data.tenureYears,
|
|
tenureStartDate: data.tenureStartDate,
|
|
tenureEndDate: data.tenureEndDate,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'berth',
|
|
entityId: id,
|
|
oldValue: diff as unknown as Record<string, unknown>,
|
|
newValue: data as unknown as Record<string, unknown>,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:updated', {
|
|
berthId: id,
|
|
changedFields: Object.keys(diff),
|
|
});
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
|
|
);
|
|
|
|
return updated!;
|
|
}
|
|
|
|
// ─── Update Status ────────────────────────────────────────────────────────────
|
|
|
|
export async function updateBerthStatus(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateBerthStatusInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
const [updated] = await db
|
|
.update(berths)
|
|
.set({
|
|
status: data.status,
|
|
statusLastChangedBy: meta.userId,
|
|
statusLastChangedReason: data.reason,
|
|
statusLastModified: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'berth',
|
|
entityId: id,
|
|
oldValue: { status: existing.status },
|
|
newValue: { status: data.status, reason: data.reason },
|
|
metadata: { type: 'status_change', reason: data.reason },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
|
|
berthId: id,
|
|
oldStatus: existing.status,
|
|
newStatus: data.status,
|
|
triggeredBy: meta.userId,
|
|
});
|
|
|
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
|
dispatchWebhookEvent(portId, 'berth:statusChanged', {
|
|
berthId: id,
|
|
oldStatus: existing.status,
|
|
newStatus: data.status,
|
|
}),
|
|
);
|
|
|
|
return updated!;
|
|
}
|
|
|
|
// ─── Set Tags ─────────────────────────────────────────────────────────────────
|
|
|
|
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)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
const result = await setEntityTags({
|
|
joinTable: berthTags,
|
|
entityColumn: berthTags.berthId,
|
|
tagColumn: berthTags.tagId,
|
|
entityId: id,
|
|
portId,
|
|
tagIds,
|
|
meta,
|
|
entityType: 'berth',
|
|
});
|
|
|
|
return { berthId: result.entityId, tagIds: result.tagIds };
|
|
}
|
|
|
|
// ─── Add Maintenance Log ──────────────────────────────────────────────────────
|
|
|
|
export async function addMaintenanceLog(
|
|
id: string,
|
|
portId: string,
|
|
data: AddMaintenanceLogInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
const rows = await db
|
|
.insert(berthMaintenanceLog)
|
|
.values({
|
|
berthId: id,
|
|
portId,
|
|
category: data.category,
|
|
description: data.description,
|
|
cost: data.cost !== undefined ? String(data.cost) : undefined,
|
|
costCurrency: data.costCurrency,
|
|
responsibleParty: data.responsibleParty,
|
|
performedDate: data.performedDate,
|
|
photoFileIds: data.photoFileIds,
|
|
createdBy: meta.userId,
|
|
})
|
|
.returning();
|
|
|
|
const log = rows[0]!;
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'berth_maintenance_log',
|
|
entityId: log.id,
|
|
metadata: { berthId: id, category: data.category },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:maintenanceAdded', {
|
|
berthId: id,
|
|
logEntry: log,
|
|
});
|
|
|
|
return log;
|
|
}
|
|
|
|
// ─── Get Maintenance Logs ─────────────────────────────────────────────────────
|
|
|
|
export async function getMaintenanceLogs(id: string, portId: string) {
|
|
const existing = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
return db
|
|
.select()
|
|
.from(berthMaintenanceLog)
|
|
.where(and(eq(berthMaintenanceLog.berthId, id), eq(berthMaintenanceLog.portId, portId)))
|
|
.orderBy(berthMaintenanceLog.performedDate);
|
|
}
|
|
|
|
// ─── Get Waiting List ─────────────────────────────────────────────────────────
|
|
|
|
export async function getWaitingList(id: string, portId: string) {
|
|
const existing = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
return db
|
|
.select()
|
|
.from(berthWaitingList)
|
|
.where(eq(berthWaitingList.berthId, id))
|
|
.orderBy(berthWaitingList.position);
|
|
}
|
|
|
|
// ─── Update Waiting List ──────────────────────────────────────────────────────
|
|
|
|
export async function updateWaitingList(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateWaitingListInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
const existing = await db.query.berths.findFirst({
|
|
where: and(eq(berths.id, id), eq(berths.portId, portId)),
|
|
});
|
|
if (!existing) throw new NotFoundError('Berth');
|
|
|
|
// Validate every supplied clientId belongs to portId. Without this
|
|
// check, a port-A admin could insert port-B clientIds into the
|
|
// waiting list - corrupting reportable data and creating a join
|
|
// surface that hydrates foreign-tenant client rows.
|
|
if (data.entries.length > 0) {
|
|
const clientIds = [...new Set(data.entries.map((e) => e.clientId))];
|
|
const validClients = await db
|
|
.select({ id: clients.id })
|
|
.from(clients)
|
|
.where(and(inArray(clients.id, clientIds), eq(clients.portId, portId)));
|
|
if (validClients.length !== clientIds.length) {
|
|
throw new ValidationError('One or more clients are not in this port');
|
|
}
|
|
}
|
|
|
|
// Replace entire waiting list
|
|
await db.delete(berthWaitingList).where(eq(berthWaitingList.berthId, id));
|
|
|
|
if (data.entries.length > 0) {
|
|
await db.insert(berthWaitingList).values(
|
|
data.entries.map((entry) => ({
|
|
berthId: id,
|
|
clientId: entry.clientId,
|
|
position: entry.position,
|
|
priority: entry.priority ?? 'normal',
|
|
notifyPref: entry.notifyPref ?? 'email',
|
|
notes: entry.notes,
|
|
})),
|
|
);
|
|
}
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'berth',
|
|
entityId: id,
|
|
metadata: { type: 'waiting_list_updated', count: data.entries.length },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'berth:waitingListChanged', {
|
|
berthId: id,
|
|
action: 'replaced',
|
|
entry: data.entries,
|
|
});
|
|
|
|
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?.toString(),
|
|
voltage: data.voltage?.toString(),
|
|
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) {
|
|
return db
|
|
.select({
|
|
id: berths.id,
|
|
mooringNumber: berths.mooringNumber,
|
|
area: berths.area,
|
|
status: berths.status,
|
|
})
|
|
.from(berths)
|
|
.where(eq(berths.portId, portId))
|
|
.orderBy(berths.mooringNumber);
|
|
}
|