feat: wire inquiry notifications into public interest endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user