Files
pn-new-crm/src/app/api/public/residential-inquiries/route.ts
Matt Ciaccio 16d98d630e feat(i18n): country/phone/timezone/subdivision primitives + form wiring
Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.

PR1  Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
     for localized labels, detectDefaultCountry() with navigator-region
     fallback to US, CountryCombobox with regional-indicator flag glyphs +
     compact mode for inline use.
PR2  Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
     callingCodeFor), PhoneInput with flag dropdown + national-format
     AsYouType + paste-detect that flips the country dropdown for pasted
     international strings.
PR3  Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
     ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
     TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4  Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
     codes for every country), per-country cache, SubdivisionCombobox with
     "Pick a country first" / "No regions available" empty states.
PR5  Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
     {value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
     residentialClients {phone_e164, phone_country, nationality_iso, timezone,
     place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
     country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
     subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
     by every entity validator + route handler.
PR6  ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
     TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
     PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
     editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
     for the detail-page overview rows + ContactsEditor.
PR7  Residential client form + detail — phone -> PhoneInput, nationality/
     timezone/place-of-residence-country/subdivision rows in both create
     sheet and inline-editable detail view. Subdivision wipes when country
     flips since codes are country-scoped.
PR8  Company form + detail — incorporation country -> CountryCombobox,
     incorporation region -> SubdivisionCombobox in both modes.
PR9  Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
     and i18n fields from newer website builds, server-side parsePhone()
     fallback for legacy raw-international submissions. Old Nuxt builds
     keep working unchanged.

Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.

Test totals: vitest 713 -> 741 (+28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:13:08 +02:00

195 lines
6.8 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 { env } from '@/lib/env';
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
import { logger } from '@/lib/logger';
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';
// ─── Rate limiter (5 per hour per IP) ────────────────────────────────────────
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000;
const MAX_HITS = 5;
function checkRateLimit(ip: string): void {
const now = Date.now();
const entry = ipHits.get(ip);
if (!entry || now > entry.resetAt) {
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return;
}
if (entry.count >= MAX_HITS) {
throw new RateLimitError(Math.ceil((entry.resetAt - now) / 1000));
}
entry.count += 1;
}
/**
* 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';
checkRateLimit(ip);
const body = await req.json();
const data = publicResidentialInquirySchema.parse(body);
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;
// Client confirmation
const confirmation = residentialClientConfirmation({
firstName: data.firstName,
contactEmail: 'sales@portnimara.com',
});
await sendEmail(data.email, confirmation.subject, confirmation.html);
// 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 = 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,
});
await sendEmail(recipients, alert.subject, alert.html);
}