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> = {}; 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>).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, data as Record, ); 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, newValue: data as unknown as Record, 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); }