Files
pn-new-crm/src/lib/services/berths.service.ts
Matt ca51000401 feat(uat-batch-12): password-reveal env messaging + berth Latest-stage sortable
- registry-driven-form password-reveal eye toggle: when the value is
  resolved from env / default fallback (not port / global override),
  the toggle is now disabled with a tooltip explaining "Value comes
  from the environment. Configure in admin to enable reveal." Stops
  the silent-no-op confusion that read as a broken toggle.
- Berth list: 'Latest deal stage' column dropped enableSorting:false.
  Service-side adds a stageSort correlated subquery that ranks each
  berth by the highest active interest's pipelineStage (enquiry=1 →
  contract=7); NULLS LAST regardless of direction so empty rows
  always land at the bottom.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:11:17 +02:00

1264 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, count, desc, eq, gte, lte, inArray, isNull, notInArray, 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 { interestBerths, interests } from '@/lib/db/schema/interests';
import { tags } from '@/lib/db/schema/system';
import { PIPELINE_STAGES } from '@/lib/constants';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { diffEntity } from '@/lib/entity-diff';
import { ConflictError, 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 { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
import { sortByMooring } from '@/lib/utils/mooring-sort';
import type {
CreateBerthInput,
UpdateBerthInput,
UpdateBerthStatusInput,
UpdateBerthPriceInput,
BulkUpdateBerthPricesInput,
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;
case 'activeInterestCount':
// Sorted via correlated subquery in customOrderBy below.
return null;
case 'latestInterestStage':
// Sorted via correlated subquery in customOrderBy below — the
// column doesn't exist on berths; it's the highest-ranked
// active interest's pipeline stage per berth.
return null;
default:
// No sort requested → natural mooring order is the friendliest
// default for the berth grid (groups by pontoon letter).
return null;
}
})();
// Sort by active interest count via correlated subquery. Cheap at
// marina scale (hundreds of berths × thousands of interests); revisit
// with a LATERAL join if multi-port reporting ever hits this hot.
const demandSort =
query.sort === 'activeInterestCount'
? [
sql`(
SELECT COUNT(*)::int
FROM ${interestBerths} ib
INNER JOIN ${interests} i ON i.id = ib.interest_id
WHERE ib.berth_id = ${berths.id}
AND i.port_id = ${portId}
AND i.archived_at IS NULL
AND i.outcome IS NULL
) ${sql.raw(query.order === 'asc' ? 'ASC' : 'DESC')}`,
]
: null;
// Sort by highest active pipeline stage per berth. Berths with no
// active interest get NULL; we land them at the bottom regardless of
// direction by paired ORDER BY rank + NULLS LAST.
const stageDirection = query.order === 'asc' ? 'ASC' : 'DESC';
const stageSort =
query.sort === 'latestInterestStage'
? [
sql`(
SELECT MAX(
CASE i.pipeline_stage
WHEN 'enquiry' THEN 1
WHEN 'qualified' THEN 2
WHEN 'nurturing' THEN 3
WHEN 'eoi' THEN 4
WHEN 'reservation' THEN 5
WHEN 'deposit_paid' THEN 6
WHEN 'contract' THEN 7
ELSE 0
END
)
FROM ${interestBerths} ib
INNER JOIN ${interests} i ON i.id = ib.interest_id
WHERE ib.berth_id = ${berths.id}
AND i.port_id = ${portId}
AND i.archived_at IS NULL
AND i.outcome IS NULL
) ${sql.raw(stageDirection)} NULLS LAST`,
]
: 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: stageSort ?? demandSort ?? (sortColumn ? undefined : NATURAL_MOORING_SORT),
page: query.page,
pageSize: query.limit,
searchColumns: [berths.mooringNumber, berths.area],
searchTerm: query.search,
// berths.archivedAt + ?includeArchived flag landed in migration 0065.
// Default the admin list to active-only; an `?includeArchived=true`
// query string surfaces the archive bin for ops.
archivedAtColumn: berths.archivedAt,
includeArchived: Boolean(query.includeArchived),
});
// 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 latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
const interestCountByBerthId = await getActiveInterestCountByBerth(berthIds, portId);
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
...b,
tags: tagsByBerthId[b.id as string] ?? [],
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
activeInterestCount: interestCountByBerthId[b.id as string] ?? 0,
}));
return { data, total: result.total };
}
/**
* Per-berth active interest count. Mirrors the demand-sort subquery so
* the column value and the sort key stay consistent.
*/
async function getActiveInterestCountByBerth(
berthIds: string[],
portId: string,
): Promise<Record<string, number>> {
if (berthIds.length === 0) return {};
const rows = await db
.select({
berthId: interestBerths.berthId,
count: count(interests.id),
})
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)))
.groupBy(interestBerths.berthId);
const map: Record<string, number> = {};
for (const row of rows) {
map[row.berthId] = Number(row.count);
}
return map;
}
/**
* For each berth id, returns the most-advanced pipeline stage among its
* linked active interests (outcome IS NULL, not archived). Used by the
* berth list + detail to surface the deal furthest along on a berth so
* reps can see at a glance whether a berth is "Reservation Sent" via
* its connected interest, even though berth.status only tracks
* available/under_offer/sold.
*/
async function getLatestInterestStageByBerth(
berthIds: string[],
portId: string,
): Promise<Record<string, string>> {
if (berthIds.length === 0) return {};
const rows = await db
.select({
berthId: interestBerths.berthId,
pipelineStage: interests.pipelineStage,
})
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)));
// Pipeline stages are an ordered enum — rank by position in PIPELINE_STAGES
// so "contract_signed" beats "eoi_sent". Falls back to 0 for any unknown
// legacy values so they're treated as least-advanced.
const rankOf = (stage: string) => {
const idx = (PIPELINE_STAGES as readonly string[]).indexOf(stage);
return idx === -1 ? -1 : idx;
};
const top: Record<string, string> = {};
for (const row of rows) {
const current = top[row.berthId];
if (!current || rankOf(row.pipelineStage) > rankOf(current)) {
top[row.berthId] = row.pipelineStage;
}
}
return top;
}
// ─── 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));
const latestStageMap = await getLatestInterestStageByBerth([id], portId);
return {
...berth,
tags: tagRows,
latestInterestStage: latestStageMap[id] ?? null,
};
}
// ─── 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: toAuditJson(diff),
newValue: toAuditJson(data),
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 Price (single + bulk) ─────────────────────────────────────────────
/**
* Update a single berth's price (+ optional currency). Gated upstream by
* the `berths.update_prices` permission so sales reps can retune prices
* without the full `berths.edit` surface. Writes a focused audit row
* with `field_changed='price'` and the before/after values so the audit
* log carries a clean price-change history.
*/
export async function updateBerthPrice(
id: string,
portId: string,
data: UpdateBerthPriceInput,
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 oldPrice = existing.price;
const oldCurrency = existing.priceCurrency;
const newPrice = data.price === null ? null : String(data.price);
const newCurrency = data.priceCurrency ?? oldCurrency;
if (oldPrice === newPrice && oldCurrency === newCurrency) {
return existing;
}
const [updated] = await db
.update(berths)
.set({
price: newPrice,
priceCurrency: newCurrency,
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,
fieldChanged: 'price',
oldValue: toAuditJson({ price: oldPrice, priceCurrency: oldCurrency }),
newValue: toAuditJson({ price: newPrice, priceCurrency: newCurrency }),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:updated', {
berthId: id,
changedFields: ['price'],
});
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
);
return updated!;
}
/**
* Bulk berth price update. Loads all targeted rows in one query so we
* can validate port ownership in the same trip, then writes each change
* with a per-row audit entry. Berths that resolve to the same price are
* skipped (counted as `unchanged` in the response) so retries are idempotent.
*
* Returns counts so the UI can present "updated N, skipped M, missing K".
*/
export async function bulkUpdateBerthPrices(
portId: string,
data: BulkUpdateBerthPricesInput,
meta: AuditMeta,
) {
const targetIds = data.updates.map((u) => u.berthId);
const existingRows = await db
.select({
id: berths.id,
price: berths.price,
priceCurrency: berths.priceCurrency,
})
.from(berths)
.where(and(eq(berths.portId, portId), inArray(berths.id, targetIds)));
const byId = new Map(existingRows.map((b) => [b.id, b]));
const missing: string[] = [];
const updatedIds: string[] = [];
const unchangedIds: string[] = [];
await db.transaction(async (tx) => {
for (const u of data.updates) {
const existing = byId.get(u.berthId);
if (!existing) {
missing.push(u.berthId);
continue;
}
const oldPrice = existing.price;
const oldCurrency = existing.priceCurrency;
const newPrice = u.price === null ? null : String(u.price);
const newCurrency = u.priceCurrency ?? oldCurrency;
if (oldPrice === newPrice && oldCurrency === newCurrency) {
unchangedIds.push(u.berthId);
continue;
}
await tx
.update(berths)
.set({
price: newPrice,
priceCurrency: newCurrency,
updatedAt: new Date(),
})
.where(and(eq(berths.id, u.berthId), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: u.berthId,
fieldChanged: 'price',
oldValue: toAuditJson({ price: oldPrice, priceCurrency: oldCurrency }),
newValue: toAuditJson({ price: newPrice, priceCurrency: newCurrency }),
metadata: { source: 'bulk_update_prices', batchSize: data.updates.length },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
updatedIds.push(u.berthId);
}
});
// Realtime fan-out — one event per updated berth so any open list view
// refetches the affected rows.
for (const id of updatedIds) {
emitToRoom(`port:${portId}`, 'berth:updated', {
berthId: id,
changedFields: ['price'],
});
}
if (updatedIds.length > 0) {
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => {
for (const id of updatedIds) {
dispatchWebhookEvent(portId, 'berth:updated', { berthId: id });
}
});
}
return {
updated: updatedIds.length,
unchanged: unchangedIds.length,
missing: missing.length,
missingIds: missing,
};
}
// ─── 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');
// #67 Phase 1: stamp the source of this write so the reconciliation
// queue (and the "Manual" chip on the row) can later distinguish a
// human-set status from a rules-engine auto-set status. The rules
// engine sets this to 'automated' on its own write path; user-facing
// API hits always end up here.
const [updated] = await db
.update(berths)
.set({
status: data.status,
statusLastChangedBy: meta.userId,
statusLastChangedReason: data.reason,
statusLastModified: new Date(),
statusOverrideMode: 'manual',
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,
}),
);
// Optional: link the chosen interest as the primary holder of this
// berth. Cross-port checks live inside the helper so a malicious
// interestId from another port can't slip past the status PATCH.
if (data.interestId) {
const { setPrimaryBerth } = await import('@/lib/services/interest-berths.service');
await setPrimaryBerth(data.interestId, id);
}
return updated!;
}
// ─── Reconciliation Queue ─────────────────────────────────────────────────────
//
// #67 Phase 3: surfaces every berth whose status was set manually (i.e.
// statusOverrideMode === 'manual') AND that has no active linked interest
// backing the status change. These are the rows the catch-up wizard
// targets — a rep flipped them to under_offer / sold without ever
// creating the matching deal. Sorted by status_last_modified DESC so the
// freshest manual flips show up first.
interface ReconcileRow {
id: string;
mooringNumber: string;
area: string | null;
status: string;
statusLastChangedBy: string | null;
statusLastChangedReason: string | null;
statusLastModified: Date | null;
}
export async function listManualReconcileBerths(portId: string): Promise<{
data: ReconcileRow[];
total: number;
}> {
// Use a NOT EXISTS subquery against interest_berths joined with the active
// interests predicate so a berth currently linked to any open deal drops
// out of the queue — even if the rep set the status manually first and
// only later created the interest, that follow-up is the catch-up.
const activeBerthIds = db
.select({ berthId: interestBerths.berthId })
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.where(activeInterestsWhere(portId));
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
statusLastChangedBy: berths.statusLastChangedBy,
statusLastChangedReason: berths.statusLastChangedReason,
statusLastModified: berths.statusLastModified,
})
.from(berths)
.where(
and(
eq(berths.portId, portId),
eq(berths.statusOverrideMode, 'manual'),
isNull(berths.archivedAt),
notInArray(berths.id, activeBerthIds),
),
)
.orderBy(desc(berths.statusLastModified));
return { data: rows, total: rows.length };
}
// ─── Reconcile Manual Override ────────────────────────────────────────────────
//
// #67 Phase 1: called by the catch-up wizard once a backing interest is in
// place. Clears `statusOverrideMode` so the berth drops out of the
// reconciliation queue, and stamps the reason with the interest id so the
// audit trail records the reconciliation event explicitly.
//
// Intentionally NOT called from setPrimaryBerth/upsertInterestBerth — those
// run on every berth-link write (including drag-drop reorders that have
// nothing to do with a manual override) and would silently clear the flag
// behind the rep's back. Only the wizard owns the clear semantics.
export async function clearBerthOverride(
berthId: string,
portId: string,
reconciledInterestId: string,
meta: AuditMeta,
): Promise<void> {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
await db
.update(berths)
.set({
statusOverrideMode: null,
statusLastChangedReason: `Reconciled via interest ${reconciledInterestId}`,
statusLastChangedBy: meta.userId,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, berthId), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: berthId,
oldValue: { statusOverrideMode: existing.statusOverrideMode ?? null },
newValue: { statusOverrideMode: null, reconciledInterestId },
metadata: { type: 'reconcile_manual', reconciledInterestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Catch-up Reconcile ───────────────────────────────────────────────────────
//
// #67 Phase 4: orchestrates "rep set the berth manually, now create the
// backing interest so the row drops out of the reconciliation queue".
//
// Intentionally a thin orchestrator over the existing client / interest
// service helpers (each of which already runs in its own transaction
// with its own audit-log emit). We pull them together here so the API
// layer has a single call to make, but the actual work stays inside the
// already-tested helpers — wrapping ALL of this in one transaction would
// require restructuring the audit-log emits to be queued + flushed at
// commit, which is out of scope for this feature.
interface ReconcileBerthInput {
clientId?: string;
newClient?: { fullName: string; email?: string; phone?: string };
yachtId?: string;
pipelineStage: string;
outcome?: 'won' | null;
outcomeReason?: string;
}
export async function reconcileBerthWithNewInterest(
berthId: string,
portId: string,
input: ReconcileBerthInput,
meta: AuditMeta,
): Promise<{ interestId: string; clientId: string }> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
if (berth.statusOverrideMode !== 'manual') {
throw new ValidationError('Berth is not in a manual-override state');
}
// Lazy imports so this module doesn't pull in the entire interest/client
// service surface (and create circular import chains).
const [{ createClient }, { createInterest }] = await Promise.all([
import('@/lib/services/clients.service'),
import('@/lib/services/interests.service'),
]);
let clientId = input.clientId;
if (!clientId) {
if (!input.newClient?.fullName) {
throw new ValidationError('Either clientId or newClient.fullName is required');
}
const contacts: Array<{
channel: 'email' | 'phone' | 'whatsapp' | 'other';
value: string;
isPrimary: boolean;
}> = [];
if (input.newClient.email) {
contacts.push({ channel: 'email', value: input.newClient.email.trim(), isPrimary: true });
}
if (input.newClient.phone) {
contacts.push({
channel: 'phone',
value: input.newClient.phone.trim(),
isPrimary: contacts.length === 0,
});
}
const created = await createClient(
portId,
{
fullName: input.newClient.fullName.trim(),
contacts,
tagIds: [],
} as unknown as Parameters<typeof createClient>[1],
meta,
);
clientId = created.id;
}
const interest = await createInterest(
portId,
{
clientId,
yachtId: input.yachtId ?? null,
berthId,
pipelineStage: input.pipelineStage,
outcome: input.outcome ?? null,
outcomeReason: input.outcomeReason ?? null,
assignedTo: meta.userId,
tagIds: [],
} as unknown as Parameters<typeof createInterest>[1],
meta,
);
await clearBerthOverride(berthId, portId, interest.id, meta);
return { interestId: interest.id, clientId: clientId! };
}
// ─── 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`);
}
// Caller-specified currency wins; otherwise inherit the port's admin-
// configured default (system_settings.berths_default_currency, USD if
// unset). Lets a multi-currency portfolio be modelled cleanly without
// forcing reps to pick a currency on every new-berth form.
const resolvedCurrency = data.priceCurrency ?? (await getPortBerthsDefaultCurrency(portId));
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: resolvedCurrency,
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!;
}
// ─── Bulk add ───────────────────────────────────────────────────────────────
export async function bulkAddBerths(
portId: string,
inputs: CreateBerthInput[],
meta: AuditMeta,
): Promise<{ inserted: number; ids: string[] }> {
// Input-level dedup: catch fat-finger duplicates in the wizard before
// hitting the unique index.
const seenMoorings = new Set<string>();
for (const row of inputs) {
if (seenMoorings.has(row.mooringNumber)) {
throw new ConflictError(`Duplicate mooring number "${row.mooringNumber}" in input`);
}
seenMoorings.add(row.mooringNumber);
}
const moorings = inputs.map((r) => r.mooringNumber);
const existing = await db
.select({ mooringNumber: berths.mooringNumber })
.from(berths)
.where(and(eq(berths.portId, portId), inArray(berths.mooringNumber, moorings)));
if (existing.length > 0) {
throw new ConflictError(
`Mooring numbers already exist in this port: ${existing.map((r) => r.mooringNumber).join(', ')}`,
);
}
const defaultCurrency = await getPortBerthsDefaultCurrency(portId);
const values = inputs.map((row) => ({
portId,
mooringNumber: row.mooringNumber,
area: row.area,
status: row.status ?? 'available',
lengthFt: row.lengthFt?.toString(),
lengthM: row.lengthM?.toString(),
widthFt: row.widthFt?.toString(),
widthM: row.widthM?.toString(),
draftFt: row.draftFt?.toString(),
draftM: row.draftM?.toString(),
price: row.price?.toString(),
priceCurrency: row.priceCurrency ?? defaultCurrency,
tenureType: row.tenureType ?? 'permanent',
mooringType: row.mooringType,
powerCapacity: row.powerCapacity?.toString(),
voltage: row.voltage?.toString(),
access: row.access,
bowFacing: row.bowFacing,
sidePontoon: row.sidePontoon,
}));
const inserted = await db.insert(berths).values(values).returning({ id: berths.id });
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth',
entityId: 'bulk',
newValue: { count: inserted.length, mooringNumbers: moorings },
metadata: { type: 'bulk_add' },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:bulk_created',
message: `${inserted.length} berths added`,
severity: 'info',
});
return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
}
// ─── Archive / Restore ─────────────────────────────────────────────────────
/**
* Post-audit F5: soft-archive replaces hard-delete. The previous
* `db.delete()` permanently dropped the berth row + cascade-vanished
* interest_berths links + broke historical audit references. Now the
* row stays; `archived_at` shields it from default queries.
*
* Reasoning chain:
* 1. Block if there's an active (non-archived, no-outcome) interest
* still linked — archiving with deals in flight breaks reports.
* 2. Stamp archived_at + archived_by + archive_reason in a single update.
* 3. Audit log captures the reason so /admin/audit shows the why.
* 4. Emit a socket alert so any open berth-detail page bounces.
*/
export async function archiveBerth(
id: string,
portId: string,
input: { reason: 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');
if (berth.archivedAt) {
throw new ConflictError('Berth is already archived');
}
// Block archive when an active interest still depends on the berth —
// forces the rep to resolve the deal first instead of orphaning it.
const activeLink = await db
.select({ interestId: interestBerths.interestId })
.from(interestBerths)
.innerJoin(interests, eq(interests.id, interestBerths.interestId))
.where(
and(eq(interestBerths.berthId, id), isNull(interests.archivedAt), isNull(interests.outcome)),
)
.limit(1);
if (activeLink.length > 0) {
throw new ConflictError(
'Cannot archive a berth with an active interest. Resolve or archive the interest first.',
);
}
await db
.update(berths)
.set({
archivedAt: new Date(),
archivedBy: meta.userId,
archiveReason: input.reason,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'archive',
entityType: 'berth',
entityId: id,
oldValue: { mooringNumber: berth.mooringNumber, area: berth.area, status: berth.status },
newValue: { reason: input.reason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:archived',
message: `Berth "${berth.mooringNumber}" archived: ${input.reason}`,
severity: 'info',
});
}
/** Un-archive. Available to anyone with `berths:edit`. Audit-logged. */
export async function restoreBerth(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');
if (!berth.archivedAt) {
throw new ConflictError('Berth is not archived');
}
await db
.update(berths)
.set({
archivedAt: null,
archivedBy: null,
archiveReason: null,
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'restore',
entityType: 'berth',
entityId: id,
oldValue: { archivedAt: berth.archivedAt, archiveReason: berth.archiveReason },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'system:alert', {
alertType: 'berth:restored',
message: `Berth "${berth.mooringNumber}" restored`,
severity: 'info',
});
}
/**
* @deprecated Use `archiveBerth` instead. Kept temporarily for callers
* that haven't migrated. Calls archiveBerth under the hood — the
* "hard delete" name is now a lie but we don't break the import sites
* in a single PR.
*/
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
return archiveBerth(id, portId, { reason: 'Deleted via legacy delete path' }, meta);
}
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {
// DB-side `ORDER BY mooring_number` is lexicographic (A1, A10, A11, A2…).
// Natural-sort in JS so dropdowns surface them as reps read them: A1, A2,
// …, A10, A11. See compareMooringNumbers for the prefix/index split.
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
})
.from(berths)
// F5: hide archived berths from option pickers; otherwise a dead berth
// appears in the New Interest combobox and re-links itself to a deal.
.where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
return sortByMooring(rows, (r) => r.mooringNumber);
}