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>
216 lines
7.6 KiB
TypeScript
216 lines
7.6 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { and, eq } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { withTransaction } from '@/lib/db/utils';
|
|
import { ports } from '@/lib/db/schema/ports';
|
|
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
|
import { systemSettings } from '@/lib/db/schema/system';
|
|
import { sendEmail } from '@/lib/email';
|
|
import {
|
|
residentialClientConfirmation,
|
|
residentialSalesAlert,
|
|
} from '@/lib/email/templates/residential-inquiry';
|
|
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';
|
|
import { publicResidentialInquirySchema } from '@/lib/validators/residential';
|
|
import { emitToRoom } from '@/lib/socket/server';
|
|
import { parsePhone } from '@/lib/i18n/phone';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
|
|
/**
|
|
* Throws RateLimitError if the IP has exceeded the public-form quota.
|
|
* Backed by the Redis sliding-window limiter so the cap survives restarts
|
|
* and is shared across worker processes.
|
|
*/
|
|
async function gateRateLimit(ip: string): Promise<void> {
|
|
const result = await checkRateLimit(ip, rateLimiters.publicForm);
|
|
if (!result.allowed) {
|
|
const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
|
|
throw new RateLimitError(retryAfter);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/public/residential-inquiries - unauthenticated entry point for
|
|
* the public website's residential interest form. Creates a
|
|
* `residential_clients` row and an opening `residential_interests` row in a
|
|
* single transaction.
|
|
*
|
|
* Required: `portId` query param or `X-Port-Id` header.
|
|
*/
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
|
|
await gateRateLimit(ip);
|
|
|
|
const data = await parseBody(req, publicResidentialInquirySchema);
|
|
|
|
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
|
|
if (!portId) {
|
|
throw new ValidationError('portId is required');
|
|
}
|
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
|
if (!port) {
|
|
throw new ValidationError('Unknown port');
|
|
}
|
|
|
|
// If the website didn't pre-normalize, parse server-side. International
|
|
// strings parse without a hint; national-format submissions need a country.
|
|
let phoneE164 = data.phoneE164 ?? null;
|
|
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
|
|
if (!phoneE164) {
|
|
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
|
|
phoneE164 = parsed.e164;
|
|
phoneCountry = parsed.country ?? phoneCountry;
|
|
}
|
|
|
|
const result = await withTransaction(async (tx) => {
|
|
const [client] = await tx
|
|
.insert(residentialClients)
|
|
.values({
|
|
portId,
|
|
fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
|
|
email: data.email,
|
|
phone: data.phone,
|
|
phoneE164,
|
|
phoneCountry,
|
|
nationalityIso: data.nationalityIso ?? null,
|
|
timezone: data.timezone ?? null,
|
|
placeOfResidence: data.placeOfResidence,
|
|
placeOfResidenceCountryIso: data.placeOfResidenceCountryIso ?? null,
|
|
subdivisionIso: data.subdivisionIso ?? null,
|
|
preferredContactMethod: data.preferredContactMethod,
|
|
source: 'website',
|
|
status: 'prospect',
|
|
notes: data.notes,
|
|
})
|
|
.returning();
|
|
if (!client) throw new Error('Failed to create residential client');
|
|
|
|
const [interest] = await tx
|
|
.insert(residentialInterests)
|
|
.values({
|
|
portId,
|
|
residentialClientId: client.id,
|
|
pipelineStage: 'new',
|
|
source: 'website',
|
|
notes: data.notes,
|
|
preferences: data.preferences,
|
|
})
|
|
.returning();
|
|
if (!interest) throw new Error('Failed to create residential interest');
|
|
|
|
return { clientId: client.id, interestId: interest.id };
|
|
});
|
|
|
|
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
|
|
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
|
|
|
|
// Send notification emails (non-blocking - failures shouldn't 500 the
|
|
// public form).
|
|
void sendResidentialNotifications({
|
|
portId,
|
|
data,
|
|
crmDeepLink: `${env.APP_URL}/${port.slug}/residential/clients/${result.clientId}`,
|
|
}).catch((err) => logger.error({ err }, 'Failed to send residential inquiry notifications'));
|
|
|
|
return NextResponse.json({ success: true, ...result }, { status: 201 });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
async function sendResidentialNotifications(args: {
|
|
portId: string;
|
|
data: {
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
phone: string;
|
|
placeOfResidence?: string;
|
|
preferredContactMethod?: 'email' | 'phone';
|
|
notes?: string;
|
|
preferences?: string;
|
|
};
|
|
crmDeepLink: string;
|
|
}): Promise<void> {
|
|
const { portId, data, crmDeepLink } = args;
|
|
|
|
const branding = await getBrandingShell(portId);
|
|
// Client confirmation
|
|
const confirmation = await residentialClientConfirmation(
|
|
{
|
|
firstName: data.firstName,
|
|
contactEmail: 'sales@portnimara.com',
|
|
},
|
|
{ branding },
|
|
);
|
|
const confirmationSubject = await resolveSubject({
|
|
key: 'residential_inquiry_client_confirmation',
|
|
portId,
|
|
fallback: confirmation.subject,
|
|
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
|
});
|
|
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
|
|
|
|
// Sales-team alert - pull recipients from system_settings if configured;
|
|
// fall back to the inquiry_contact_email if available.
|
|
const recipientsRow = await db.query.systemSettings.findFirst({
|
|
where: and(
|
|
eq(systemSettings.key, 'residential_notification_recipients'),
|
|
eq(systemSettings.portId, portId),
|
|
),
|
|
});
|
|
const fallbackRow = await db.query.systemSettings.findFirst({
|
|
where: and(eq(systemSettings.key, 'inquiry_contact_email'), eq(systemSettings.portId, portId)),
|
|
});
|
|
|
|
const configured = Array.isArray(recipientsRow?.value) ? (recipientsRow!.value as string[]) : [];
|
|
const fallback =
|
|
typeof fallbackRow?.value === 'string' && fallbackRow.value.length > 0
|
|
? [fallbackRow.value]
|
|
: [];
|
|
const recipients = configured.length > 0 ? configured : fallback;
|
|
|
|
if (recipients.length === 0) {
|
|
logger.warn(
|
|
{ portId },
|
|
'No residential_notification_recipients or inquiry_contact_email configured; skipping sales alert',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const alert = await residentialSalesAlert(
|
|
{
|
|
fullName: `${data.firstName} ${data.lastName}`.trim(),
|
|
email: data.email,
|
|
phone: data.phone,
|
|
placeOfResidence: data.placeOfResidence,
|
|
preferredContactMethod: data.preferredContactMethod,
|
|
notes: data.notes,
|
|
preferences: data.preferences,
|
|
crmDeepLink,
|
|
},
|
|
{ branding },
|
|
);
|
|
const alertSubject = await resolveSubject({
|
|
key: 'residential_inquiry_sales_alert',
|
|
portId,
|
|
fallback: alert.subject,
|
|
tokens: {
|
|
portName: 'Port Nimara',
|
|
clientName: `${data.firstName} ${data.lastName}`.trim(),
|
|
email: data.email,
|
|
phone: data.phone,
|
|
},
|
|
});
|
|
|
|
await sendEmail(recipients, alertSubject, alert.html);
|
|
}
|