feat: wire inquiry notifications into public interest endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 13:04:26 -04:00
parent 7313d8b3d0
commit d0c12d74e4

View File

@@ -3,10 +3,13 @@ import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { clients, clientContacts } from '@/lib/db/schema/clients'; import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { createAuditLog } from '@/lib/audit'; import { createAuditLog } from '@/lib/audit';
import { errorResponse, RateLimitError } from '@/lib/errors'; import { errorResponse, RateLimitError } from '@/lib/errors';
import { publicInterestSchema } from '@/lib/validators/interests'; import { publicInterestSchema } from '@/lib/validators/interests';
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
// ─── Simple in-memory rate limiter ─────────────────────────────────────────── // ─── Simple in-memory rate limiter ───────────────────────────────────────────
// Max 5 requests per hour per IP // Max 5 requests per hour per IP
@@ -47,90 +50,68 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Port context required' }, { status: 400 }); return NextResponse.json({ error: 'Port context required' }, { status: 400 });
} }
// Resolve the full name
const fullName =
data.firstName && data.lastName
? `${data.firstName} ${data.lastName}`
: (data.fullName ?? 'Unknown');
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided)
let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
if (data.mooringNumber) {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
});
if (berth) {
berthId = berth.id;
resolvedMooringNumber = berth.mooringNumber;
}
}
// Find or create client by email // Find or create client by email
let clientId: string; let clientId: string;
const existingContact = await db.query.clientContacts.findFirst({ const existingContact = await db.query.clientContacts.findFirst({
where: and( where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
eq(clientContacts.channel, 'email'),
eq(clientContacts.value, data.email),
),
}); });
if (existingContact) { if (existingContact) {
// Find the client associated with this contact
const existingClient = await db.query.clients.findFirst({ const existingClient = await db.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId), where: eq(clients.id, existingContact.clientId),
}); });
if (existingClient && existingClient.portId === portId) { if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id; clientId = existingClient.id;
} else { // Update preferred contact method if provided
// Create new client for this port if (data.preferredContactMethod) {
const [newClient] = await db await db
.insert(clients) .update(clients)
.values({ .set({ preferredContactMethod: data.preferredContactMethod })
portId, .where(eq(clients.id, clientId));
fullName: data.fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
source: 'website',
})
.returning();
clientId = newClient!.id;
await db.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
if (data.phone) {
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
} }
} else {
clientId = await createNewClient(portId, fullName, data);
} }
} else { } else {
// Create brand-new client clientId = await createNewClient(portId, fullName, data);
const [newClient] = await db }
.insert(clients)
.values({
portId,
fullName: data.fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
source: 'website',
})
.returning();
clientId = newClient!.id;
await db.insert(clientContacts).values({ // Store address if provided
if (data.address && Object.values(data.address).some(Boolean)) {
await db.insert(clientAddresses).values({
clientId, clientId,
channel: 'email', portId,
value: data.email, label: 'Primary',
streetAddress: data.address.street ?? null,
city: data.address.city ?? null,
stateProvince: data.address.stateProvince ?? null,
postalCode: data.address.postalCode ?? null,
country: data.address.country ?? null,
isPrimary: true, isPrimary: true,
}); });
if (data.phone) {
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
}
} }
// Create the interest // Create the interest
@@ -139,6 +120,7 @@ export async function POST(req: NextRequest) {
.values({ .values({
portId, portId,
clientId, clientId,
berthId,
source: 'website', source: 'website',
pipelineStage: 'open', pipelineStage: 'open',
notes: data.notes, notes: data.notes,
@@ -151,12 +133,29 @@ export async function POST(req: NextRequest) {
action: 'create', action: 'create',
entityType: 'interest', entityType: 'interest',
entityId: interest!.id, entityId: interest!.id,
newValue: { clientId, source: 'website', pipelineStage: 'open' }, newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
metadata: { type: 'public_registration', ip }, metadata: { type: 'public_registration', ip },
ipAddress: ip, ipAddress: ip,
userAgent: req.headers.get('user-agent') ?? 'unknown', userAgent: req.headers.get('user-agent') ?? 'unknown',
}); });
// Fire notifications asynchronously (non-blocking)
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),
columns: { slug: true },
});
void sendInquiryNotifications({
portId,
portSlug: port?.slug ?? portId,
interestId: interest!.id,
clientFullName: fullName,
clientEmail: data.email,
clientPhone: data.phone,
mooringNumber: resolvedMooringNumber,
firstName,
});
return NextResponse.json( return NextResponse.json(
{ data: { id: interest!.id, message: 'Interest registered successfully' } }, { data: { id: interest!.id, message: 'Interest registered successfully' } },
{ status: 201 }, { status: 201 },
@@ -165,3 +164,52 @@ export async function POST(req: NextRequest) {
return errorResponse(error); return errorResponse(error);
} }
} }
async function createNewClient(
portId: string,
fullName: string,
data: {
email: string;
phone: string;
companyName?: string;
yachtName?: string;
yachtLengthFt?: number;
yachtWidthFt?: number;
yachtDraftFt?: number;
preferredBerthSize?: string;
preferredContactMethod?: string;
},
): Promise<string> {
const [newClient] = await db
.insert(clients)
.values({
portId,
fullName,
companyName: data.companyName,
yachtName: data.yachtName,
yachtLengthFt: data.yachtLengthFt != null ? String(data.yachtLengthFt) : undefined,
yachtWidthFt: data.yachtWidthFt != null ? String(data.yachtWidthFt) : undefined,
yachtDraftFt: data.yachtDraftFt != null ? String(data.yachtDraftFt) : undefined,
berthSizeDesired: data.preferredBerthSize,
preferredContactMethod: data.preferredContactMethod,
source: 'website',
})
.returning();
const clientId = newClient!.id;
await db.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
await db.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
return clientId;
}