Files
pn-new-crm/src/app/api/public/interests/route.ts

311 lines
11 KiB
TypeScript
Raw Normal View History

import { NextRequest, NextResponse } from 'next/server';
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { z } from 'zod';
import { db } from '@/lib/db';
import { withTransaction } from '@/lib/db/utils';
import { interests } from '@/lib/db/schema/interests';
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, RateLimitError } from '@/lib/errors';
import { publicInterestSchema } from '@/lib/validators/interests';
import { sendInquiryNotifications } from '@/lib/services/inquiry-notifications.service';
// ─── Simple in-memory rate limiter ───────────────────────────────────────────
// Max 5 requests per hour per IP
const ipHits = new Map<string, { count: number; resetAt: number }>();
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
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) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
throw new RateLimitError(retryAfter);
}
entry.count += 1;
}
type PublicInterestData = z.infer<typeof publicInterestSchema>;
// `withTransaction` exposes its tx argument as `typeof db` (see lib/db/utils.ts).
// Keep the helper aligned with that.
type Tx = typeof db;
// POST /api/public/interests — unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction.
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 = publicInterestSchema.parse(body);
// Resolve portId from query param or header (public endpoints need explicit port)
const portId = req.nextUrl.searchParams.get('portId') ?? req.headers.get('X-Port-Id');
if (!portId) {
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
}
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). Read-only lookup — safe
// to do outside the transaction.
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;
}
}
// ─── Transactional trio creation ────────────────────────────────────────
const result = await withTransaction(async (tx) => {
// 1. Find or create client by email (case-sensitive contact match, same
// behavior as before the refactor).
let clientId: string;
const existingContact = await tx.query.clientContacts.findFirst({
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
});
if (existingContact) {
const existingClient = await tx.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
if (data.preferredContactMethod) {
await tx
.update(clients)
.set({ preferredContactMethod: data.preferredContactMethod })
.where(eq(clients.id, clientId));
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data);
}
} else {
clientId = await createClientInTx(tx, portId, fullName, data);
}
// 2. Optional: upsert company + add membership
let companyId: string | null = null;
if (data.company) {
const existingCompany = await tx.query.companies.findFirst({
where: and(
eq(companies.portId, portId),
sql`lower(${companies.name}) = lower(${data.company.name})`,
),
});
if (existingCompany) {
companyId = existingCompany.id;
} else {
const [newCompany] = await tx
.insert(companies)
.values({
portId,
name: data.company.name,
legalName: data.company.legalName ?? null,
taxId: data.company.taxId ?? null,
incorporationCountry: data.company.incorporationCountry ?? null,
status: 'active',
})
.returning();
companyId = newCompany!.id;
}
// Add active membership only if one doesn't already exist (open row).
const existingMembership = await tx.query.companyMemberships.findFirst({
where: and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.clientId, clientId),
isNull(companyMemberships.endDate),
),
});
if (!existingMembership) {
await tx.insert(companyMemberships).values({
companyId,
clientId,
role: data.company.role ?? 'representative',
startDate: new Date(),
isPrimary: false,
});
}
}
// 3. Create yacht. Owner is the company when provided, else the client.
const ownerType: 'client' | 'company' = companyId ? 'company' : 'client';
const ownerId = companyId ?? clientId;
const [newYacht] = await tx
.insert(yachts)
.values({
portId,
name: data.yacht.name,
hullNumber: data.yacht.hullNumber ?? null,
registration: data.yacht.registration ?? null,
flag: data.yacht.flag ?? null,
yearBuilt: data.yacht.yearBuilt ?? null,
lengthFt: data.yacht.lengthFt != null ? String(data.yacht.lengthFt) : null,
widthFt: data.yacht.widthFt != null ? String(data.yacht.widthFt) : null,
draftFt: data.yacht.draftFt != null ? String(data.yacht.draftFt) : null,
currentOwnerType: ownerType,
currentOwnerId: ownerId,
status: 'active',
})
.returning();
const yachtId = newYacht!.id;
// 3a. Open ownership_history row for the new yacht.
await tx.insert(yachtOwnershipHistory).values({
yachtId,
ownerType,
ownerId,
startDate: new Date(),
endDate: null,
createdBy: 'public-submission',
});
// 4. Store address if provided AND no primary address exists yet.
if (data.address && Object.values(data.address).some(Boolean)) {
const existingAddr = await tx.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)),
});
if (!existingAddr) {
await tx.insert(clientAddresses).values({
clientId,
portId,
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,
});
}
}
// 5. Create interest with yachtId wired up.
const [newInterest] = await tx
.insert(interests)
.values({
portId,
clientId,
berthId,
yachtId,
source: 'website',
pipelineStage: 'open',
notes: data.notes,
})
.returning();
return {
interestId: newInterest!.id,
clientId,
yachtId,
companyId,
};
});
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
void createAuditLog({
userId: null as unknown as string,
portId,
action: 'create',
entityType: 'interest',
entityId: result.interestId,
newValue: {
clientId: result.clientId,
yachtId: result.yachtId,
companyId: result.companyId,
source: 'website',
pipelineStage: 'open',
berthId,
},
metadata: { type: 'public_registration', ip },
ipAddress: ip,
userAgent: req.headers.get('user-agent') ?? 'unknown',
});
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),
columns: { slug: true },
});
void sendInquiryNotifications({
portId,
portSlug: port?.slug ?? portId,
interestId: result.interestId,
clientFullName: fullName,
clientEmail: data.email,
clientPhone: data.phone,
mooringNumber: resolvedMooringNumber,
firstName,
});
return NextResponse.json(
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function createClientInTx(
tx: Tx,
portId: string,
fullName: string,
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
): Promise<string> {
const [newClient] = await tx
.insert(clients)
.values({
portId,
fullName,
preferredContactMethod: data.preferredContactMethod,
source: 'website',
})
.returning();
const clientId = newClient!.id;
await tx.insert(clientContacts).values({
clientId,
channel: 'email',
value: data.email,
isPrimary: true,
});
await tx.insert(clientContacts).values({
clientId,
channel: 'phone',
value: data.phone,
isPrimary: false,
});
return clientId;
}