fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson

Address the CRITICAL + high-leverage HIGH items from the types-auditor:

**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.

**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.

**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.

**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each

document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 12:27:08 +02:00
parent b397f6049d
commit f183f58b0c
21 changed files with 78 additions and 155 deletions

View File

@@ -2,6 +2,17 @@ import { db } from '@/lib/db';
import { auditLogs } from '@/lib/db/schema';
import { logger } from '@/lib/logger';
/**
* Widen a Drizzle row (or any object) to the shape audit_logs.oldValue /
* newValue expects. Centralizes the structurally-safe `Record<string,
* unknown>` cast 20+ services were doing inline via
* `as unknown as Record<string, unknown>`. Mirrors gdpr-bundle-builder's
* `toJsonRow` helper (same audit-found motivation).
*/
export function toAuditJson<T extends object>(row: T): Record<string, unknown> {
return row as unknown as Record<string, unknown>;
}
export type AuditAction =
| 'create'
| 'update'

View File

@@ -2,6 +2,13 @@ import { eq, sql } from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index';
/**
* Drizzle transaction client type — the argument shape `db.transaction`'s
* callback receives. Exported so service helpers that take a `tx`
* parameter can spell the type instead of falling back to `any`.
*/
export type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
/**
* Wraps a database operation in a transaction.
* Rolls back automatically on error.

View File

@@ -12,6 +12,7 @@
import { and, eq, gte, sql } from 'drizzle-orm';
import { toAuditJson } from '@/lib/audit';
import { db } from '@/lib/db';
import { aiUsageLedger } from '@/lib/db/schema/ai-usage';
import { systemSettings } from '@/lib/db/schema/system';
@@ -85,13 +86,13 @@ export async function setAiBudget(
.values({
key: KEY,
portId,
value: next as unknown as Record<string, unknown>,
value: toAuditJson(next),
updatedBy: userId,
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: {
value: next as unknown as Record<string, unknown>,
value: toAuditJson(next),
updatedBy: userId,
updatedAt: new Date(),
},

View File

@@ -6,7 +6,7 @@ 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, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
@@ -287,8 +287,8 @@ export async function updateBerth(
action: 'update',
entityType: 'berth',
entityId: id,
oldValue: diff as unknown as Record<string, unknown>,
newValue: data as unknown as Record<string, unknown>,
oldValue: toAuditJson(diff),
newValue: toAuditJson(data),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});

View File

@@ -19,6 +19,7 @@
import { and, eq, isNull, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import type { Tx } from '@/lib/db/utils';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
@@ -356,12 +357,7 @@ export async function restoreClientWithSelections(args: {
};
}
async function applyReversal(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tx: any,
r: RestoreReversal,
clientId: string,
): Promise<void> {
async function applyReversal(tx: Tx, r: RestoreReversal, clientId: string): Promise<void> {
switch (r.kind) {
case 'berth_released': {
// Re-link the berth to whichever interest originally owned it

View File

@@ -10,7 +10,7 @@ import type { Company } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { withTransaction } from '@/lib/db/utils';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import {
@@ -149,10 +149,7 @@ export async function updateCompany(
throw new NotFoundError('Company');
}
const { diff } = diffEntity(
existing as unknown as Record<string, unknown>,
data as Record<string, unknown>,
);
const { diff } = diffEntity(toAuditJson(existing), data as Record<string, unknown>);
let updated: Company | undefined;
try {

View File

@@ -4,7 +4,7 @@ import { companies, companyMemberships } from '@/lib/db/schema/companies';
import type { CompanyMembership } from '@/lib/db/schema/companies';
import { clients } from '@/lib/db/schema/clients';
import { withTransaction } from '@/lib/db/utils';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { diffEntity } from '@/lib/entity-diff';
@@ -127,10 +127,7 @@ export async function updateMembership(
): Promise<CompanyMembership> {
const existing = await loadMembershipScoped(membershipId, portId);
const { diff } = diffEntity(
existing as unknown as Record<string, unknown>,
data as Record<string, unknown>,
);
const { diff } = diffEntity(toAuditJson(existing), data as Record<string, unknown>);
const rows = await db
.update(companyMemberships)

View File

@@ -16,7 +16,7 @@ import { berthReservations } from '@/lib/db/schema/reservations';
import { ports } from '@/lib/db/schema/ports';
import { userProfiles, userPortRoles } from '@/lib/db/schema/users';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { CodedError, NotFoundError, ValidationError, ConflictError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
@@ -580,8 +580,8 @@ export async function updateDocument(
action: 'update',
entityType: 'document',
entityId: id,
oldValue: existing as unknown as Record<string, unknown>,
newValue: updated as unknown as Record<string, unknown>,
oldValue: toAuditJson(existing),
newValue: toAuditJson(updated!),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});

View File

@@ -4,7 +4,7 @@ import type { PgColumn } from 'drizzle-orm/pg-core';
import { db } from '@/lib/db';
import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import { CodedError, NotFoundError } from '@/lib/errors';
@@ -171,7 +171,7 @@ export async function createExpense(portId: string, data: CreateExpenseInput, me
action: 'create',
entityType: 'expense',
entityId: expense.id,
newValue: expense as unknown as Record<string, unknown>,
newValue: toAuditJson(expense),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -250,7 +250,7 @@ export async function updateExpense(
if (data.amount !== undefined) updateData.amount = String(data.amount);
const { diff } = diffEntity(existing as unknown as Record<string, unknown>, updateData);
const { diff } = diffEntity(toAuditJson(existing), updateData);
const [updated] = await db
.update(expenses)
@@ -266,8 +266,8 @@ export async function updateExpense(
action: 'update',
entityType: 'expense',
entityId: id,
oldValue: existing as unknown as Record<string, unknown>,
newValue: updated as unknown as Record<string, unknown>,
oldValue: toAuditJson(existing),
newValue: toAuditJson(updated),
metadata: { diff },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
@@ -304,7 +304,7 @@ export async function archiveExpense(id: string, portId: string, meta: AuditMeta
action: 'archive',
entityType: 'expense',
entityId: id,
oldValue: existing as unknown as Record<string, unknown>,
oldValue: toAuditJson(existing),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -325,7 +325,7 @@ export async function restoreExpense(id: string, portId: string, meta: AuditMeta
action: 'restore',
entityType: 'expense',
entityId: id,
newValue: restored as unknown as Record<string, unknown>,
newValue: toAuditJson(restored),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});

View File

@@ -7,7 +7,7 @@ import { systemSettings } from '@/lib/db/schema/system';
import { clients, clientAddresses } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { withTransaction } from '@/lib/db/utils';
import { CodedError, NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
@@ -372,7 +372,7 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
action: 'create',
entityType: 'invoice',
entityId: invoice.id,
newValue: invoice as unknown as Record<string, unknown>,
newValue: toAuditJson(invoice),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
@@ -524,10 +524,7 @@ export async function updateInvoice(
return result;
});
const { diff } = diffEntity(
existing as unknown as Record<string, unknown>,
updated as unknown as Record<string, unknown>,
);
const { diff } = diffEntity(toAuditJson(existing), toAuditJson(updated));
void createAuditLog({
userId: meta.userId,
@@ -535,8 +532,8 @@ export async function updateInvoice(
action: 'update',
entityType: 'invoice',
entityId: id,
oldValue: existing as unknown as Record<string, unknown>,
newValue: updated as unknown as Record<string, unknown>,
oldValue: toAuditJson(existing),
newValue: toAuditJson(updated),
metadata: { diff },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
@@ -570,7 +567,7 @@ export async function deleteInvoice(id: string, portId: string, meta: AuditMeta)
action: 'delete',
entityType: 'invoice',
entityId: id,
oldValue: existing as unknown as Record<string, unknown>,
oldValue: toAuditJson(existing),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});

View File

@@ -6,6 +6,7 @@
import { and, eq, isNull } from 'drizzle-orm';
import { toAuditJson } from '@/lib/audit';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { encrypt, decrypt } from '@/lib/utils/encryption';
@@ -76,13 +77,13 @@ async function writeRow(portId: string | null, value: StoredOcrConfig, userId: s
.values({
key: KEY,
portId,
value: value as unknown as Record<string, unknown>,
value: toAuditJson(value),
updatedBy: userId,
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: {
value: value as unknown as Record<string, unknown>,
value: toAuditJson(value),
updatedBy: userId,
updatedAt: new Date(),
},

View File

@@ -3,7 +3,7 @@ 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, type AuditMeta } from '@/lib/audit';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import {
@@ -138,10 +138,7 @@ export async function updateYacht(
throw new NotFoundError('Yacht');
}
const { diff } = diffEntity(
existing as unknown as Record<string, unknown>,
data as Record<string, unknown>,
);
const { diff } = diffEntity(toAuditJson(existing), data as Record<string, unknown>);
const [updated] = await db
.update(yachts)