feat(public-interest): atomic client+yacht+company+interest trio
Restructures the public interest endpoint to create the yacht as a first-class row (owned by the new client, or by a newly upserted company when a company block is provided) and writes the yacht_id onto the new interest. All writes now run inside a single transaction instead of the previous unwrapped sequence. The public validator gains structured `yacht` (required) and `company` (optional) sub-objects; legacy flat fields remain in the schema for backward compatibility but are silently ignored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
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';
|
||||
@@ -35,7 +39,14 @@ function checkRateLimit(ip: string): void {
|
||||
entry.count += 1;
|
||||
}
|
||||
|
||||
// POST /api/public/interests — unauthenticated public interest registration
|
||||
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';
|
||||
@@ -50,7 +61,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Port context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve the full name
|
||||
const fullName =
|
||||
data.firstName && data.lastName
|
||||
? `${data.firstName} ${data.lastName}`
|
||||
@@ -58,10 +68,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||
|
||||
// Resolve berth by mooring number (if provided)
|
||||
// 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)),
|
||||
@@ -72,74 +82,172 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create client by email
|
||||
let clientId: string;
|
||||
|
||||
const existingContact = await db.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
});
|
||||
|
||||
if (existingContact) {
|
||||
const existingClient = await db.query.clients.findFirst({
|
||||
where: eq(clients.id, existingContact.clientId),
|
||||
// ─── 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 (existingClient && existingClient.portId === portId) {
|
||||
clientId = existingClient.id;
|
||||
// Update preferred contact method if provided
|
||||
if (data.preferredContactMethod) {
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ preferredContactMethod: data.preferredContactMethod })
|
||||
.where(eq(clients.id, clientId));
|
||||
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 createNewClient(portId, fullName, data);
|
||||
clientId = await createClientInTx(tx, portId, fullName, data);
|
||||
}
|
||||
} else {
|
||||
clientId = await createNewClient(portId, fullName, data);
|
||||
}
|
||||
|
||||
// Store address if provided
|
||||
if (data.address && Object.values(data.address).some(Boolean)) {
|
||||
await db.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,
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
|
||||
// Create the interest
|
||||
const [interest] = await db
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId,
|
||||
// 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,
|
||||
berthId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
yachtId,
|
||||
companyId,
|
||||
};
|
||||
});
|
||||
|
||||
// ─── Post-commit side-effects (fire-and-forget) ─────────────────────────
|
||||
void createAuditLog({
|
||||
userId: null as unknown as string,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'interest',
|
||||
entityId: interest!.id,
|
||||
newValue: { clientId, source: 'website', pipelineStage: 'open', berthId },
|
||||
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',
|
||||
});
|
||||
|
||||
// Fire notifications asynchronously (non-blocking)
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
columns: { slug: true },
|
||||
@@ -148,7 +256,7 @@ export async function POST(req: NextRequest) {
|
||||
void sendInquiryNotifications({
|
||||
portId,
|
||||
portSlug: port?.slug ?? portId,
|
||||
interestId: interest!.id,
|
||||
interestId: result.interestId,
|
||||
clientFullName: fullName,
|
||||
clientEmail: data.email,
|
||||
clientPhone: data.phone,
|
||||
@@ -157,7 +265,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ data: { id: interest!.id, message: 'Interest registered successfully' } },
|
||||
{ data: { id: result.interestId, message: 'Interest registered successfully' } },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -165,46 +273,33 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewClient(
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function createClientInTx(
|
||||
tx: Tx,
|
||||
portId: string,
|
||||
fullName: string,
|
||||
data: {
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName?: string;
|
||||
yachtName?: string;
|
||||
yachtLengthFt?: number;
|
||||
yachtWidthFt?: number;
|
||||
yachtDraftFt?: number;
|
||||
preferredBerthSize?: string;
|
||||
preferredContactMethod?: string;
|
||||
},
|
||||
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod'>,
|
||||
): Promise<string> {
|
||||
const [newClient] = await db
|
||||
const [newClient] = await tx
|
||||
.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({
|
||||
await tx.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
await db.insert(clientContacts).values({
|
||||
await tx.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'phone',
|
||||
value: data.phone,
|
||||
|
||||
Reference in New Issue
Block a user