Replaces every em-dash and en-dash with regular ASCII hyphens across comments, JSX strings, and dev-facing logs. Mostly cosmetic but stops the inconsistent mix that crept in over the last few months (some files used em-dashes in comments, others didn't, some used both). Bundles two small dashboard-layout tweaks that touch a couple of already-modified files: - (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6 pb-6 so page content sits closer to the topbar. - Sidebar now receives the ports list it needs for the footer port switcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
6.7 KiB
TypeScript
190 lines
6.7 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 { 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 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);
|
|
}
|