Files
pn-new-crm/src/lib/services/berths.service.ts
Matt Ciaccio e40b6c3d99 feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.

Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
  (NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
  forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
  strings convert cleanly

Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
  four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type

Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
  side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
  area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
  access
- power capacity / voltage become numeric inputs (with kW / V hints)

Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
  ("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00

554 lines
18 KiB
TypeScript

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 { 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));
}
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;
}
})();
const result = await buildListQuery({
table: berths,
portIdColumn: berths.portId,
portId,
idColumn: berths.id,
updatedAtColumn: berths.updatedAt,
filters,
sort: { column: sortColumn, direction: query.order },
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);
}