Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
471
src/lib/services/berths.service.ts
Normal file
471
src/lib/services/berths.service.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
berths,
|
||||
berthTags,
|
||||
berthWaitingList,
|
||||
berthMaintenanceLog,
|
||||
berthMapData,
|
||||
} 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);
|
||||
}
|
||||
Reference in New Issue
Block a user