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

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { errorResponse, ValidationError } from '@/lib/errors';
import { errorResponse } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
const bodySchema = z.object({
token: z.string().min(1),
@@ -16,24 +16,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
if (limited) return limited;
try {
let body: unknown;
try {
body = await req.json();
} catch {
// Use {error} via errorResponse so the envelope matches every other
// route (auditor-F §32 — was emitting {message} as a third variant).
throw new ValidationError('Invalid request body');
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid input');
}
const result = await consumeCrmInvite({
token: parsed.data.token,
password: parsed.data.password,
});
const { token, password } = await parseBody(req, bodySchema);
const result = await consumeCrmInvite({ token, password });
return NextResponse.json({ data: { email: result.email } });
} catch (err) {
return errorResponse(err);

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { activateAccount } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({
@@ -16,19 +16,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
if (limited) return limited;
try {
let body: unknown;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid request body');
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid input');
}
await activateAccount(parsed.data.token, parsed.data.password);
const { token, password } = await parseBody(req, bodySchema);
await activateAccount(token, password);
return NextResponse.json({ success: true });
} catch (err) {
return errorResponse(err);

View File

@@ -4,7 +4,8 @@ import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { portalUsers } from '@/lib/db/schema/portal';
import { errorResponse, UnauthorizedError, ValidationError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, UnauthorizedError } from '@/lib/errors';
import { getPortalSession } from '@/lib/portal/auth';
import { changePortalPassword } from '@/lib/services/portal-auth.service';
@@ -18,13 +19,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
const session = await getPortalSession();
if (!session) throw new UnauthorizedError('Portal session required');
let body: unknown;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid request body');
}
const { currentPassword, newPassword } = bodySchema.parse(body);
const { currentPassword, newPassword } = await parseBody(req, bodySchema);
const user = await db.query.portalUsers.findFirst({
where: eq(portalUsers.email, session.email),

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { enforcePublicRateLimit, parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { resetPassword } from '@/lib/services/portal-auth.service';
const bodySchema = z.object({
@@ -16,19 +16,8 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
if (limited) return limited;
try {
let body: unknown;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid request body');
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.issues[0]?.message ?? 'Invalid input');
}
await resetPassword(parsed.data.token, parsed.data.password);
const { token, password } = await parseBody(req, bodySchema);
await resetPassword(token, password);
return NextResponse.json({ success: true });
} catch (err) {
return errorResponse(err);

View File

@@ -11,6 +11,7 @@ import { ports } from '@/lib/db/schema/ports';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { publicInterestSchema } from '@/lib/validators/interests';
@@ -44,8 +45,7 @@ export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
await gateRateLimit(ip);
const body = await req.json();
const data = publicInterestSchema.parse(body);
const data = await parseBody(req, publicInterestSchema);
// Resolve portId from query param or header (public endpoints need explicit port)
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');

View File

@@ -14,6 +14,7 @@ import {
import { resolveSubject } from '@/lib/email/resolve-subject';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { env } from '@/lib/env';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
@@ -48,8 +49,7 @@ export async function POST(req: NextRequest) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
await gateRateLimit(ip);
const body = await req.json();
const data = publicResidentialInquirySchema.parse(body);
const data = await parseBody(req, publicResidentialInquirySchema);
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
if (!portId) {