Files
pn-new-crm/src/lib/services/berths.service.ts

471 lines
14 KiB
TypeScript
Raw Normal View History

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 { 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 type {
UpdateBerthInput,
UpdateBerthStatusInput,
ListBerthsQuery,
AddMaintenanceLogInput,
UpdateWaitingListInput,
} from '@/lib/validators/berths';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── 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: data.nominalBoatSize,
nominalBoatSizeM: data.nominalBoatSizeM,
waterDepth: n(data.waterDepth),
waterDepthM: n(data.waterDepthM),
waterDepthIsMinimum: data.waterDepthIsMinimum,
sidePontoon: data.sidePontoon,
powerCapacity: data.powerCapacity,
voltage: 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');
// Delete existing tags then insert new ones
await db.delete(berthTags).where(eq(berthTags.berthId, id));
if (tagIds.length > 0) {
await db.insert(berthTags).values(tagIds.map((tagId) => ({ berthId: id, tagId })));
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: id,
metadata: { type: 'tags_updated', tagIds },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:updated', {
berthId: id,
changedFields: ['tags'],
});
return { berthId: id, 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');
// 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;
}
// ─── 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);
}