import { NextRequest, NextResponse } from 'next/server'; import { timingSafeEqual } from 'node:crypto'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; import { env } from '@/lib/env'; import { AppError, errorResponse, RateLimitError, UnauthorizedError, ValidationError, } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; /** * POST /api/public/website-inquiries * * Capture endpoint for the marketing website's dual-write. The website * server (`/server/api/register.ts`, `/server/api/contact.ts`) calls this * AFTER its existing NocoDB write succeeds, sending the same payload as a * server-to-server fire-and-forget POST. The CRM stores the raw payload * in `website_submissions` for later analysis / promotion to entities. * * Auth: shared-secret in `X-Webhook-Secret` header, timing-safe compared * against `WEBSITE_INTAKE_SECRET`. If the env var is unset on this * instance, the endpoint refuses every request with 503 - the correct * posture for dev/staging that hasn't been wired up yet. * * Idempotency: payload carries a `submission_id` UUID. The unique index * on `website_submissions.submission_id` makes redelivery a no-op; the * handler returns 200 + the existing record's id instead of erroring. * * No emails / no `interests` rows are created here. The endpoint's job is * pure data capture. A separate "promote" step (future) will turn captured * submissions into proper `clients` + `interests` rows once we trust the * pipeline. */ const SubmissionSchema = z.object({ submission_id: z.string().uuid(), kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']), payload: z.record(z.string(), z.unknown()), legacy_nocodb_id: z.string().optional(), /** Defaults to port-nimara since that's currently the only port with a * public marketing site. Future ports can override per-submission. */ port_slug: z.string().default('port-nimara'), /** UTM attribution. Opportunistic — the website's tracker pulls * these from the query string (or referrer) at submit time. Capped * at 200 chars per part to defend against pathological strings. */ utm_source: z.string().max(200).nullable().optional(), utm_medium: z.string().max(200).nullable().optional(), utm_campaign: z.string().max(200).nullable().optional(), utm_term: z.string().max(200).nullable().optional(), utm_content: z.string().max(200).nullable().optional(), }); function verifySecret(header: string | null): boolean { const expected = env.WEBSITE_INTAKE_SECRET; if (!expected) return false; if (!header) return false; // Timing-safe compare requires equal-length buffers; pad to whichever is // longer so an early-exit on length mismatch can't leak the secret length. const a = Buffer.from(header); const b = Buffer.from(expected); const pad = Buffer.alloc(Math.max(a.length, b.length)); const aPad = Buffer.concat([a, pad]).subarray(0, pad.length); const bPad = Buffer.concat([b, pad]).subarray(0, pad.length); return timingSafeEqual(aPad, bPad) && a.length === b.length; } export async function POST(req: NextRequest) { // Refuse outright if the CRM hasn't been wired up - safer than letting // unauthenticated traffic in just because the env var was forgotten. if (!env.WEBSITE_INTAKE_SECRET) { return errorResponse( new AppError(503, 'Website intake is not configured on this server.', 'NOT_CONFIGURED'), ); } // Auth gate - shared secret in header, timing-safe compare. const secretHeader = req.headers.get('x-webhook-secret'); if (!verifySecret(secretHeader)) { return errorResponse(new UnauthorizedError()); } // Rate limit. All website-side traffic shares the website's egress IP, // so we use a dedicated bucket sized to accommodate normal traffic // (500/hr) rather than the 5/hr publicForm bucket meant for individual // human submissions. The shared-secret header is the real abuse // boundary; this limiter is just a backstop if the secret ever leaks. const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; const rl = await checkRateLimit(ip, rateLimiters.websiteIntake); if (!rl.allowed) { const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)); return errorResponse(new RateLimitError(retryAfter)); } // Parse + validate body. Reject anything that doesn't conform - the // website is a known caller; a malformed payload signals tampering. let parsed; try { const body = await req.json(); parsed = SubmissionSchema.parse(body); } catch (err) { return errorResponse( new ValidationError('Invalid payload', [ { field: 'body', message: err instanceof Error ? err.message : 'parse error' }, ]), ); } // Resolve port. We require the slug to exist; can't capture submissions // for a port the CRM doesn't know about. const [port] = await db .select({ id: ports.id }) .from(ports) .where(eq(ports.slug, parsed.port_slug)) .limit(1); if (!port) { // Don't echo the input slug back in the error - generic message is // sufficient and avoids the input-reflection pattern that complicates // log-injection / audit reviews. The slug is logged server-side // for debugging. logger.warn( { portSlug: parsed.port_slug, submissionId: parsed.submission_id }, 'website-inquiry rejected: unknown port', ); return errorResponse(new ValidationError('Unknown port')); } // Idempotent insert. Two parallel requests carrying the same submission_id // could both pass any pre-check, so we don't pre-check at all - the unique // index on submission_id is the source of truth, and `onConflictDoNothing` // keeps the second request's INSERT from raising 23505. When the conflict // hits, `returning()` yields zero rows and we look up the existing row to // return its id, mirroring the first-delivery shape so the website never // sees a difference between fresh and dup. const insertResult = await db .insert(websiteSubmissions) .values({ portId: port.id, submissionId: parsed.submission_id, kind: parsed.kind, payload: parsed.payload, legacyNocodbId: parsed.legacy_nocodb_id ?? null, sourceIp: ip, userAgent: req.headers.get('user-agent') ?? null, utmSource: parsed.utm_source ?? null, utmMedium: parsed.utm_medium ?? null, utmCampaign: parsed.utm_campaign ?? null, utmTerm: parsed.utm_term ?? null, utmContent: parsed.utm_content ?? null, }) .onConflictDoNothing({ target: websiteSubmissions.submissionId }) .returning({ id: websiteSubmissions.id }); if (insertResult[0]) { logger.info( { submissionId: parsed.submission_id, kind: parsed.kind, portSlug: parsed.port_slug, legacyNocodbId: parsed.legacy_nocodb_id, }, 'website inquiry captured', ); // L34 carve-out: deliberate bespoke `{ id, deduped }` shape (NOT the // `{ data }` envelope). This is the public website's intake contract — // the external marketing site reads `id`/`deduped` off the JSON root. // Both return sites below share this shape on purpose. Changing it // would be a breaking cross-repo change. return NextResponse.json({ id: insertResult[0].id, deduped: false }); } // Conflict path: row already exists. Fetch its id so the response shape // stays identical regardless of which request "won" the race. const existing = await db .select({ id: websiteSubmissions.id }) .from(websiteSubmissions) .where(eq(websiteSubmissions.submissionId, parsed.submission_id)) .limit(1); if (existing[0]) { return NextResponse.json({ id: existing[0].id, deduped: true }); } // Should be unreachable - the conflict means a row exists, so the lookup // above should always find it. If it doesn't (e.g. simultaneous DELETE), // surface a 500 explicitly rather than silently 200ing a missing id. logger.error( { submissionId: parsed.submission_id }, 'website-inquiry conflict but row not found on lookup', ); return errorResponse(new Error('website-inquiry conflict but row not found on lookup')); }