177 lines
6.0 KiB
TypeScript
177 lines
6.0 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';
|
||
|
|
|
||
|
|
// ─── 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');
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
placeOfResidence: data.placeOfResidence,
|
||
|
|
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);
|
||
|
|
}
|