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 { 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 { 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 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 { 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); }