Extracts transferOwnershipTx (close open yacht_ownership_history row + open a new one + update denormalized owner) from transferOwnership, and uses it in client-archive + client-restore instead of writing only the denormalized columns — which left the ledger showing the old owner as current and let the next real transfer close the wrong row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
543 lines
18 KiB
TypeScript
543 lines
18 KiB
TypeScript
import { and, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
|
import { db } from '@/lib/db';
|
|
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
|
|
import type { Yacht } from '@/lib/db/schema/yachts';
|
|
import { companies } from '@/lib/db/schema/companies';
|
|
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
|
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
|
import { logger } from '@/lib/logger';
|
|
import {
|
|
syncEntityFolderName,
|
|
applyEntityArchivedSuffix,
|
|
} from '@/lib/services/document-folders.service';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
|
import { diffEntity } from '@/lib/entity-diff';
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
|
import { withTransaction } from '@/lib/db/utils';
|
|
import type { z } from 'zod';
|
|
import type {
|
|
createYachtSchema,
|
|
UpdateYachtInput,
|
|
TransferOwnershipInput,
|
|
ListYachtsInput,
|
|
} from '@/lib/validators/yachts';
|
|
|
|
type CreateYachtInput = z.input<typeof createYachtSchema>;
|
|
|
|
async function assertOwnerExists(
|
|
portId: string,
|
|
owner: { type: 'client' | 'company'; id: string },
|
|
tx: typeof db,
|
|
): Promise<void> {
|
|
if (owner.type === 'client') {
|
|
const client = await tx.query.clients.findFirst({
|
|
where: and(eq(clients.id, owner.id), eq(clients.portId, portId)),
|
|
});
|
|
if (!client) throw new ValidationError('owner not found');
|
|
} else {
|
|
const company = await tx.query.companies.findFirst({
|
|
where: and(eq(companies.id, owner.id), eq(companies.portId, portId)),
|
|
});
|
|
if (!company) throw new ValidationError('owner not found');
|
|
}
|
|
}
|
|
|
|
export async function createYacht(portId: string, data: CreateYachtInput, meta: AuditMeta) {
|
|
return await withTransaction(async (tx) => {
|
|
await assertOwnerExists(portId, data.owner, tx);
|
|
|
|
const [yacht] = await tx
|
|
.insert(yachts)
|
|
.values({
|
|
portId,
|
|
name: data.name,
|
|
hullNumber: data.hullNumber ?? null,
|
|
registration: data.registration ?? null,
|
|
flag: data.flag ?? null,
|
|
yearBuilt: data.yearBuilt ?? null,
|
|
builder: data.builder ?? null,
|
|
model: data.model ?? null,
|
|
hullMaterial: data.hullMaterial ?? null,
|
|
lengthFt: data.lengthFt ?? null,
|
|
widthFt: data.widthFt ?? null,
|
|
draftFt: data.draftFt ?? null,
|
|
lengthM: data.lengthM ?? null,
|
|
widthM: data.widthM ?? null,
|
|
draftM: data.draftM ?? null,
|
|
currentOwnerType: data.owner.type,
|
|
currentOwnerId: data.owner.id,
|
|
status: data.status ?? 'active',
|
|
notes: data.notes ?? null,
|
|
// Phase 3c - origin tracking. Defaults to 'manual' at the DB
|
|
// level; pass-through allows the EOI spawn flow to mark the row
|
|
// as 'eoi-generated' with the generating document_id.
|
|
source: data.source ?? 'manual',
|
|
sourceDocumentId: data.sourceDocumentId ?? null,
|
|
})
|
|
.returning();
|
|
|
|
await tx.insert(yachtOwnershipHistory).values({
|
|
yachtId: yacht!.id,
|
|
ownerType: data.owner.type,
|
|
ownerId: data.owner.id,
|
|
startDate: new Date(),
|
|
endDate: null,
|
|
createdBy: meta.userId,
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'create',
|
|
entityType: 'yacht',
|
|
entityId: yacht!.id,
|
|
newValue: { name: yacht!.name, owner: data.owner },
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'yacht:created', { yachtId: yacht!.id });
|
|
|
|
return yacht!;
|
|
});
|
|
}
|
|
|
|
export async function getYachtById(id: string, portId: string) {
|
|
const yacht = await db.query.yachts.findFirst({
|
|
where: and(eq(yachts.id, id), eq(yachts.portId, portId)),
|
|
with: {
|
|
tags: { with: { tag: true } },
|
|
},
|
|
});
|
|
if (!yacht) throw new NotFoundError('Yacht');
|
|
const { tags: tagJoins, ...rest } = yacht as typeof yacht & {
|
|
tags: Array<{ tag: { id: string; name: string; color: string } }>;
|
|
};
|
|
|
|
// Aggregated note count for the Notes tab badge. Mirrors the
|
|
// symmetric-reach used by the NotesList that renders below it.
|
|
const { countForYachtAggregated } = await import('@/lib/services/notes.service');
|
|
const noteCount = await countForYachtAggregated(portId, id).catch(() => 0);
|
|
|
|
return {
|
|
...rest,
|
|
tags: tagJoins.map((t) => t.tag),
|
|
noteCount,
|
|
};
|
|
}
|
|
|
|
export async function updateYacht(
|
|
id: string,
|
|
portId: string,
|
|
data: UpdateYachtInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
// Defense-in-depth: owner changes must go through /transfer, not PATCH.
|
|
const dataRecord = data as Record<string, unknown>;
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(dataRecord, 'currentOwnerType') ||
|
|
Object.prototype.hasOwnProperty.call(dataRecord, 'currentOwnerId')
|
|
) {
|
|
throw new ValidationError('use /transfer to change ownership');
|
|
}
|
|
|
|
const existing = await db.query.yachts.findFirst({
|
|
where: eq(yachts.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Yacht');
|
|
}
|
|
|
|
const { diff } = diffEntity(toAuditJson(existing), data as Record<string, unknown>);
|
|
|
|
const [updated] = await db
|
|
.update(yachts)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(and(eq(yachts.id, id), eq(yachts.portId, portId)))
|
|
.returning();
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'update',
|
|
entityType: 'yacht',
|
|
entityId: id,
|
|
oldValue: diff as Record<string, unknown>,
|
|
newValue: data as Record<string, unknown>,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'yacht:updated', {
|
|
yachtId: id,
|
|
changedFields: Object.keys(diff),
|
|
});
|
|
|
|
if (data.name !== undefined) {
|
|
await syncEntityFolderName(portId, 'yacht', id, meta.userId).catch((err) => {
|
|
logger.warn({ err, yachtId: id, portId }, 'Failed to sync yacht folder name');
|
|
});
|
|
}
|
|
|
|
return updated!;
|
|
}
|
|
|
|
export async function archiveYacht(id: string, portId: string, meta: AuditMeta) {
|
|
const existing = await db.query.yachts.findFirst({
|
|
where: eq(yachts.id, id),
|
|
});
|
|
|
|
if (!existing || existing.portId !== portId) {
|
|
throw new NotFoundError('Yacht');
|
|
}
|
|
|
|
// NOTE: bypassing the shared `softDelete(...)` util: it sets the raw
|
|
// column key `archived_at`, which Drizzle does not recognise (the JS
|
|
// key is `archivedAt`) and therefore emits an empty SET clause. Until
|
|
// the utility is fixed, do the update inline.
|
|
await db
|
|
.update(yachts)
|
|
.set({ archivedAt: new Date() })
|
|
.where(and(eq(yachts.id, id), eq(yachts.portId, portId)));
|
|
|
|
void applyEntityArchivedSuffix(portId, 'yacht', id, meta.userId).catch((err) => {
|
|
logger.warn({ err, yachtId: id, portId }, 'Failed to apply archived suffix to yacht folder');
|
|
});
|
|
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'archive',
|
|
entityType: 'yacht',
|
|
entityId: id,
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'yacht:archived', { yachtId: id });
|
|
}
|
|
|
|
/**
|
|
* Transaction-aware ownership transfer. Performs the FULL ledger move —
|
|
* closes the open `yacht_ownership_history` row, opens a new one for the
|
|
* new owner, and updates the yacht's denormalized current-owner columns —
|
|
* so the history ledger and the denormalized columns never drift apart.
|
|
*
|
|
* Use this from any flow that moves a yacht to a new owner inside a
|
|
* transaction (smart-archive / restore included). The public
|
|
* `transferOwnership` wraps this in its own tx + audit/socket emissions.
|
|
*
|
|
* NOTE: callers are responsible for any same-owner / owner-exists
|
|
* validation they need; this helper intentionally does only the ledger
|
|
* write so it stays composable. `effectiveDate` defaults to now() when
|
|
* omitted (archive/restore have no operator-chosen date).
|
|
*/
|
|
export async function transferOwnershipTx(
|
|
tx: typeof db,
|
|
args: {
|
|
yachtId: string;
|
|
newOwner: { type: 'client' | 'company'; id: string };
|
|
effectiveDate?: Date;
|
|
transferReason?: string | null;
|
|
transferNotes?: string | null;
|
|
createdBy: string;
|
|
},
|
|
): Promise<Yacht> {
|
|
const effectiveDate = args.effectiveDate ?? new Date();
|
|
|
|
// Close the currently-active history row (endDate IS NULL → guarded by
|
|
// the idx_yoh_active partial unique index, so there's at most one).
|
|
await tx
|
|
.update(yachtOwnershipHistory)
|
|
.set({ endDate: effectiveDate })
|
|
.where(
|
|
and(
|
|
eq(yachtOwnershipHistory.yachtId, args.yachtId),
|
|
sql`${yachtOwnershipHistory.endDate} IS NULL`,
|
|
),
|
|
);
|
|
|
|
// Open the new active row for the incoming owner.
|
|
await tx.insert(yachtOwnershipHistory).values({
|
|
yachtId: args.yachtId,
|
|
ownerType: args.newOwner.type,
|
|
ownerId: args.newOwner.id,
|
|
startDate: effectiveDate,
|
|
endDate: null,
|
|
transferReason: args.transferReason ?? null,
|
|
transferNotes: args.transferNotes ?? null,
|
|
createdBy: args.createdBy,
|
|
});
|
|
|
|
// Update denormalized current-owner columns to match the ledger head.
|
|
const [updated] = await tx
|
|
.update(yachts)
|
|
.set({
|
|
currentOwnerType: args.newOwner.type,
|
|
currentOwnerId: args.newOwner.id,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(yachts.id, args.yachtId))
|
|
.returning();
|
|
|
|
return updated!;
|
|
}
|
|
|
|
export async function transferOwnership(
|
|
yachtId: string,
|
|
portId: string,
|
|
data: TransferOwnershipInput,
|
|
meta: AuditMeta,
|
|
) {
|
|
return await withTransaction(async (tx) => {
|
|
const yacht = await tx.query.yachts.findFirst({
|
|
where: and(eq(yachts.id, yachtId), eq(yachts.portId, portId)),
|
|
});
|
|
if (!yacht) throw new NotFoundError('Yacht');
|
|
|
|
if (
|
|
yacht.currentOwnerType === data.newOwner.type &&
|
|
yacht.currentOwnerId === data.newOwner.id
|
|
) {
|
|
throw new ValidationError('same owner - nothing to transfer');
|
|
}
|
|
|
|
await assertOwnerExists(portId, data.newOwner, tx);
|
|
|
|
// Resolve old + new owner names so the audit log row reads as a
|
|
// sentence ("Matt transferred owner from Smith to Jones") rather
|
|
// than a generic "updated this record." Resolution mirrors the
|
|
// assertOwnerExists pattern — same client/company tables, scoped
|
|
// to the same port.
|
|
const resolveOwnerName = async (
|
|
ownerType: string | null,
|
|
ownerId: string | null,
|
|
): Promise<string | null> => {
|
|
if (!ownerType || !ownerId) return null;
|
|
if (ownerType === 'client') {
|
|
const row = await tx.query.clients.findFirst({
|
|
where: and(eq(clients.id, ownerId), eq(clients.portId, portId)),
|
|
columns: { fullName: true },
|
|
});
|
|
return row?.fullName ?? null;
|
|
}
|
|
if (ownerType === 'company') {
|
|
const row = await tx.query.companies.findFirst({
|
|
where: and(eq(companies.id, ownerId), eq(companies.portId, portId)),
|
|
columns: { name: true },
|
|
});
|
|
return row?.name ?? null;
|
|
}
|
|
return null;
|
|
};
|
|
const [oldOwnerName, newOwnerName] = await Promise.all([
|
|
resolveOwnerName(yacht.currentOwnerType, yacht.currentOwnerId),
|
|
resolveOwnerName(data.newOwner.type, data.newOwner.id),
|
|
]);
|
|
|
|
// Close the open history row, open the new one, and sync the
|
|
// denormalized columns — all via the shared tx-aware helper so the
|
|
// ledger invariant holds for every transfer pathway.
|
|
const updated = await transferOwnershipTx(tx, {
|
|
yachtId,
|
|
newOwner: data.newOwner,
|
|
effectiveDate: data.effectiveDate,
|
|
transferReason: data.transferReason ?? null,
|
|
transferNotes: data.transferNotes ?? null,
|
|
createdBy: meta.userId,
|
|
});
|
|
|
|
// Audit log shape designed for the EntityActivityFeed sentence
|
|
// formatter: a discrete `transfer` action + human-readable owner
|
|
// names render as "Matt transferred owner from X to Y" instead of
|
|
// the generic "updated this record." Reason + new-owner-type
|
|
// ride along in metadata for downstream consumers that need the
|
|
// structured form.
|
|
void createAuditLog({
|
|
userId: meta.userId,
|
|
portId,
|
|
action: 'transfer',
|
|
entityType: 'yacht',
|
|
entityId: yachtId,
|
|
fieldChanged: 'owner',
|
|
// oldValue/newValue are Record<string, unknown> in the audit schema;
|
|
// wrap the owner-name strings in a `name` field so the type matches
|
|
// and the feed's `formatValueForField` can pluck the readable label.
|
|
oldValue: oldOwnerName ? { name: oldOwnerName } : undefined,
|
|
newValue: newOwnerName ? { name: newOwnerName } : undefined,
|
|
metadata: {
|
|
newOwnerType: data.newOwner.type,
|
|
newOwnerId: data.newOwner.id,
|
|
reason: data.transferReason ?? null,
|
|
notes: data.transferNotes ?? null,
|
|
},
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'yacht:ownership_transferred', {
|
|
yachtId,
|
|
newOwner: data.newOwner,
|
|
});
|
|
|
|
return updated!;
|
|
});
|
|
}
|
|
|
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function listYachts(portId: string, query: ListYachtsInput) {
|
|
const { page, limit, sort, order, search, includeArchived, ownerType, ownerId, status } = query;
|
|
|
|
const filters = [];
|
|
if (ownerType) filters.push(eq(yachts.currentOwnerType, ownerType));
|
|
if (ownerId) filters.push(eq(yachts.currentOwnerId, ownerId));
|
|
if (status) filters.push(eq(yachts.status, status));
|
|
|
|
let sortColumn: typeof yachts.name | typeof yachts.createdAt | typeof yachts.updatedAt =
|
|
yachts.updatedAt;
|
|
if (sort === 'name') sortColumn = yachts.name;
|
|
else if (sort === 'createdAt') sortColumn = yachts.createdAt;
|
|
|
|
const result = await buildListQuery<Yacht>({
|
|
table: yachts,
|
|
portIdColumn: yachts.portId,
|
|
portId,
|
|
idColumn: yachts.id,
|
|
updatedAtColumn: yachts.updatedAt,
|
|
searchColumns: [yachts.name, yachts.hullNumber, yachts.registration],
|
|
searchTerm: search,
|
|
filters,
|
|
sort: sort ? { column: sortColumn, direction: order } : undefined,
|
|
page,
|
|
pageSize: limit,
|
|
includeArchived,
|
|
archivedAtColumn: yachts.archivedAt,
|
|
});
|
|
|
|
if (result.data.length === 0) return result;
|
|
|
|
// Resolve current owner names in two parallel batched queries instead of
|
|
// an N+1 fetch from the client (was 1 round-trip per row from yacht-columns).
|
|
const clientIds = result.data
|
|
.filter((y) => y.currentOwnerType === 'client')
|
|
.map((y) => y.currentOwnerId);
|
|
const companyIds = result.data
|
|
.filter((y) => y.currentOwnerType === 'company')
|
|
.map((y) => y.currentOwnerId);
|
|
|
|
const [clientRows, companyRows] = await Promise.all([
|
|
clientIds.length > 0
|
|
? db
|
|
.select({ id: clients.id, fullName: clients.fullName })
|
|
.from(clients)
|
|
.where(inArray(clients.id, clientIds))
|
|
: Promise.resolve([] as { id: string; fullName: string }[]),
|
|
companyIds.length > 0
|
|
? db
|
|
.select({ id: companies.id, name: companies.name })
|
|
.from(companies)
|
|
.where(inArray(companies.id, companyIds))
|
|
: Promise.resolve([] as { id: string; name: string }[]),
|
|
]);
|
|
|
|
const clientNames = new Map(clientRows.map((r) => [r.id, r.fullName]));
|
|
const companyNames = new Map(companyRows.map((r) => [r.id, r.name]));
|
|
|
|
return {
|
|
...result,
|
|
data: result.data.map((y) => ({
|
|
...y,
|
|
currentOwnerName:
|
|
y.currentOwnerType === 'client'
|
|
? (clientNames.get(y.currentOwnerId) ?? null)
|
|
: (companyNames.get(y.currentOwnerId) ?? null),
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ─── List for owner ───────────────────────────────────────────────────────────
|
|
|
|
export async function listYachtsForOwner(
|
|
portId: string,
|
|
ownerType: 'client' | 'company',
|
|
ownerId: string,
|
|
) {
|
|
// Owner-detail tabs only surface active yachts. Archived ones live in the
|
|
// ownership history view and are reachable by id, not via this lister.
|
|
return await db.query.yachts.findMany({
|
|
where: and(
|
|
eq(yachts.portId, portId),
|
|
eq(yachts.currentOwnerType, ownerType),
|
|
eq(yachts.currentOwnerId, ownerId),
|
|
isNull(yachts.archivedAt),
|
|
),
|
|
orderBy: (t, { desc }) => [desc(t.updatedAt)],
|
|
});
|
|
}
|
|
|
|
// ─── Ownership history ────────────────────────────────────────────────────────
|
|
|
|
export async function listOwnershipHistory(yachtId: string, portId: string) {
|
|
// First scope-check the yacht (throws NotFoundError if cross-tenant)
|
|
await getYachtById(yachtId, portId);
|
|
return await db.query.yachtOwnershipHistory.findMany({
|
|
where: eq(yachtOwnershipHistory.yachtId, yachtId),
|
|
orderBy: (t, { desc }) => [desc(t.startDate)],
|
|
});
|
|
}
|
|
|
|
// ─── Autocomplete ─────────────────────────────────────────────────────────────
|
|
|
|
export async function autocomplete(portId: string, q: string) {
|
|
// Empty query returns the top 20 most-recently-updated yachts so the
|
|
// picker has something useful to show the moment it opens, instead of
|
|
// a dead-end empty state until the rep types something.
|
|
if (!q) {
|
|
return await db
|
|
.select()
|
|
.from(yachts)
|
|
.where(eq(yachts.portId, portId))
|
|
.orderBy(desc(yachts.updatedAt))
|
|
.limit(20);
|
|
}
|
|
const pattern = `%${q}%`;
|
|
return await db
|
|
.select()
|
|
.from(yachts)
|
|
.where(
|
|
and(
|
|
eq(yachts.portId, portId),
|
|
or(
|
|
ilike(yachts.name, pattern),
|
|
ilike(yachts.hullNumber, pattern),
|
|
ilike(yachts.registration, pattern),
|
|
),
|
|
),
|
|
)
|
|
.limit(10);
|
|
}
|
|
|
|
export async function setYachtTags(
|
|
yachtId: string,
|
|
portId: string,
|
|
tagIds: string[],
|
|
meta: AuditMeta,
|
|
) {
|
|
const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, yachtId) });
|
|
if (!yacht || yacht.portId !== portId) throw new NotFoundError('Yacht');
|
|
|
|
await setEntityTags({
|
|
joinTable: yachtTags,
|
|
entityColumn: yachtTags.yachtId,
|
|
tagColumn: yachtTags.tagId,
|
|
entityId: yachtId,
|
|
portId,
|
|
tagIds,
|
|
meta,
|
|
entityType: 'yacht',
|
|
});
|
|
}
|