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:
Matt Ciaccio
2026-04-24 15:42:45 +02:00
parent bcf4c1f797
commit 1fd05a886d
3 changed files with 530 additions and 81 deletions

View File

@@ -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,36 +82,117 @@ export async function POST(req: NextRequest) {
}
}
// Find or create client by email
// ─── 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 db.query.clientContacts.findFirst({
const existingContact = await tx.query.clientContacts.findFirst({
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
});
if (existingContact) {
const existingClient = await db.query.clients.findFirst({
const existingClient = await tx.query.clients.findFirst({
where: eq(clients.id, existingContact.clientId),
});
if (existingClient && existingClient.portId === portId) {
clientId = existingClient.id;
// Update preferred contact method if provided
if (data.preferredContactMethod) {
await db
await tx
.update(clients)
.set({ preferredContactMethod: data.preferredContactMethod })
.where(eq(clients.id, clientId));
}
} else {
clientId = await createNewClient(portId, fullName, data);
clientId = await createClientInTx(tx, portId, fullName, data);
}
} else {
clientId = await createNewClient(portId, fullName, data);
clientId = await createClientInTx(tx, portId, fullName, data);
}
// Store address if provided
// 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)) {
await db.insert(clientAddresses).values({
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',
@@ -113,33 +204,50 @@ export async function POST(req: NextRequest) {
isPrimary: true,
});
}
}
// Create the interest
const [interest] = await db
// 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: 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,

View File

@@ -74,6 +74,42 @@ const addressSchema = z.object({
country: z.string().max(100).optional(),
});
// Nested yacht block. Public submissions must now include yacht data because the
// route inserts a yacht row as part of the trio (client + yacht + interest).
const publicYachtSchema = z.object({
name: z.string().min(1).max(200),
hullNumber: z.string().max(100).optional(),
registration: z.string().max(100).optional(),
flag: z.string().max(100).optional(),
yearBuilt: z.coerce.number().int().min(1800).max(2100).optional(),
lengthFt: z.coerce.number().positive().optional(),
widthFt: z.coerce.number().positive().optional(),
draftFt: z.coerce.number().positive().optional(),
});
// Optional company block. If provided, the route upserts a company row (match
// case-insensitively by (portId, name)) and adds an active membership linking
// the submitting client to the company with the chosen role.
const publicCompanySchema = z.object({
name: z.string().min(1).max(200),
legalName: z.string().max(200).optional(),
taxId: z.string().max(100).optional(),
incorporationCountry: z.string().max(100).optional(),
role: z
.enum([
'director',
'officer',
'broker',
'representative',
'legal_counsel',
'employee',
'shareholder',
'other',
])
.optional()
.default('representative'),
});
export const publicInterestSchema = z
.object({
// New: first/last split
@@ -85,15 +121,26 @@ export const publicInterestSchema = z
phone: z.string().min(1),
preferredContactMethod: z.enum(['email', 'phone', 'sms']).optional(),
mooringNumber: z.string().max(50).optional(),
companyName: z.string().optional(),
// NEW: required structured yacht block. Public submissions after the
// data-model refactor MUST include yacht data.
yacht: publicYachtSchema,
// NEW: optional company block — creates/upserts a company and adds a
// membership linking the submitting client to it.
company: publicCompanySchema.optional(),
source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
address: addressSchema.optional(),
// ─── Deprecated flat fields ────────────────────────────────────────────
// Kept in the schema so strict parse does not reject submissions from
// legacy callers, but the route IGNORES them in favor of `yacht` / `company`.
// Remove once all inbound integrations have migrated.
yachtName: z.string().optional(),
yachtLengthFt: z.coerce.number().positive().optional(),
yachtWidthFt: z.coerce.number().positive().optional(),
yachtDraftFt: z.coerce.number().positive().optional(),
preferredBerthSize: z.string().optional(),
source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
address: addressSchema.optional(),
companyName: z.string().optional(),
})
.refine((data) => data.fullName || (data.firstName && data.lastName), {
message: 'Either fullName or both firstName and lastName are required',

View File

@@ -0,0 +1,307 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { interests } from '@/lib/db/schema/interests';
import { makePort } from '../helpers/factories';
import { makeMockRequest } from '../helpers/route-tester';
// Mock fire-and-forget side-effects so the test doesn't hit Redis / external services.
vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() }));
vi.mock('@/lib/queue', () => ({
getQueue: () => ({ add: vi.fn().mockResolvedValue(undefined) }),
}));
vi.mock('@/lib/services/inquiry-notifications.service', () => ({
sendInquiryNotifications: vi.fn().mockResolvedValue(undefined),
}));
// The rate-limiter is keyed by IP header and persists across requests inside a
// single process. Use a unique IP per test call to avoid 429s.
let ipCounter = 1;
function uniqueIp(): string {
ipCounter += 1;
return `10.0.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`;
}
describe('POST /api/public/interests — trio creation', () => {
let POST: typeof import('@/app/api/public/interests/route').POST;
beforeAll(async () => {
// Import after mocks are registered.
const mod = await import('@/app/api/public/interests/route');
POST = mod.POST;
});
it('creates client + yacht + interest atomically', async () => {
const port = await makePort();
const email = `trio-client-${Math.random().toString(36).slice(2, 8)}@test.local`;
const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, {
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Alice',
lastName: 'Mariner',
email,
phone: '+10000000001',
yacht: {
name: 'Sea Star',
lengthFt: 52,
widthFt: 14,
draftFt: 6,
},
},
});
const res = await POST(req);
expect(res.status).toBe(201);
const body = await res.json();
const interestId: string = body.data.id;
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
expect(interest).toBeDefined();
expect(interest!.portId).toBe(port.id);
expect(interest!.pipelineStage).toBe('open');
expect(interest!.yachtId).not.toBeNull();
expect(interest!.clientId).not.toBeNull();
// Yacht exists, owned by the client
const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!));
expect(yacht).toBeDefined();
expect(yacht!.name).toBe('Sea Star');
expect(yacht!.currentOwnerType).toBe('client');
expect(yacht!.currentOwnerId).toBe(interest!.clientId);
// Ownership history row created
const historyRows = await db
.select()
.from(yachtOwnershipHistory)
.where(eq(yachtOwnershipHistory.yachtId, yacht!.id));
expect(historyRows.length).toBe(1);
expect(historyRows[0]!.endDate).toBeNull();
expect(historyRows[0]!.ownerType).toBe('client');
expect(historyRows[0]!.ownerId).toBe(interest!.clientId);
// Client has email + phone contacts
const contacts = await db
.select()
.from(clientContacts)
.where(eq(clientContacts.clientId, interest!.clientId));
expect(contacts.some((c) => c.channel === 'email' && c.value === email)).toBe(true);
expect(contacts.some((c) => c.channel === 'phone' && c.value === '+10000000001')).toBe(true);
});
it('creates client + company + membership + company-owned yacht + interest when company provided', async () => {
const port = await makePort();
const email = `trio-co-${Math.random().toString(36).slice(2, 8)}@test.local`;
const companyName = `Nautical Holdings ${Math.random().toString(36).slice(2, 8)}`;
const req = makeMockRequest('POST', `http://localhost/api/public/interests?portId=${port.id}`, {
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Bob',
lastName: 'Director',
email,
phone: '+10000000002',
yacht: { name: 'Corporate Cruiser', lengthFt: 80 },
company: {
name: companyName,
role: 'director',
},
},
});
const res = await POST(req);
expect(res.status).toBe(201);
const body = await res.json();
const interestId: string = body.data.id;
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
expect(interest).toBeDefined();
expect(interest!.yachtId).not.toBeNull();
// Yacht owned by the company
const [yacht] = await db.select().from(yachts).where(eq(yachts.id, interest!.yachtId!));
expect(yacht!.currentOwnerType).toBe('company');
// Company exists and matches
const [company] = await db
.select()
.from(companies)
.where(eq(companies.id, yacht!.currentOwnerId));
expect(company!.name).toBe(companyName);
expect(company!.portId).toBe(port.id);
// Ownership-history points at the company
const historyRows = await db
.select()
.from(yachtOwnershipHistory)
.where(eq(yachtOwnershipHistory.yachtId, yacht!.id));
expect(historyRows.length).toBe(1);
expect(historyRows[0]!.ownerType).toBe('company');
expect(historyRows[0]!.ownerId).toBe(company!.id);
// Active membership linking client -> company
const memberships = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, company!.id),
eq(companyMemberships.clientId, interest!.clientId),
isNull(companyMemberships.endDate),
),
);
expect(memberships.length).toBe(1);
expect(memberships[0]!.role).toBe('director');
});
it('reuses existing client when email matches (same port)', async () => {
const port = await makePort();
const email = `trio-reuse-${Math.random().toString(36).slice(2, 8)}@test.local`;
const firstReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Carol',
lastName: 'Returning',
email,
phone: '+10000000003',
yacht: { name: 'First Boat' },
},
},
);
const firstRes = await POST(firstReq);
expect(firstRes.status).toBe(201);
const firstBody = await firstRes.json();
const [firstInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, firstBody.data.id));
const originalClientId = firstInterest!.clientId;
// Second submission with the same email
const secondReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Carol',
lastName: 'Returning',
email,
phone: '+10000000003',
yacht: { name: 'Second Boat' },
},
},
);
const secondRes = await POST(secondReq);
expect(secondRes.status).toBe(201);
const secondBody = await secondRes.json();
const [secondInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, secondBody.data.id));
expect(secondInterest!.clientId).toBe(originalClientId);
// A second yacht row was created (not deduped) — each submission is its
// own inquiry about a possibly-different yacht.
const clientsMatching = await db.select().from(clients).where(eq(clients.id, originalClientId));
expect(clientsMatching.length).toBe(1);
const [secondYacht] = await db
.select()
.from(yachts)
.where(eq(yachts.id, secondInterest!.yachtId!));
expect(secondYacht!.name).toBe('Second Boat');
expect(secondYacht!.id).not.toBe(firstInterest!.yachtId);
});
it('reuses existing company when name matches case-insensitively (same port)', async () => {
const port = await makePort();
const email1 = `trio-coreuse1-${Math.random().toString(36).slice(2, 8)}@test.local`;
const email2 = `trio-coreuse2-${Math.random().toString(36).slice(2, 8)}@test.local`;
const companyName = `Harbor Partners ${Math.random().toString(36).slice(2, 8)}`;
const firstReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Dana',
lastName: 'Founder',
email: email1,
phone: '+10000000004',
yacht: { name: 'Flagship' },
company: { name: companyName, role: 'director' },
},
},
);
const firstRes = await POST(firstReq);
expect(firstRes.status).toBe(201);
const firstBody = await firstRes.json();
const [firstInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, firstBody.data.id));
const [firstYacht] = await db
.select()
.from(yachts)
.where(eq(yachts.id, firstInterest!.yachtId!));
const originalCompanyId = firstYacht!.currentOwnerId;
// Second submission — same company name, different casing, different client
const secondReq = makeMockRequest(
'POST',
`http://localhost/api/public/interests?portId=${port.id}`,
{
headers: { 'x-forwarded-for': uniqueIp() },
body: {
firstName: 'Evan',
lastName: 'Employee',
email: email2,
phone: '+10000000005',
yacht: { name: 'Second Flagship' },
company: { name: companyName.toUpperCase(), role: 'employee' },
},
},
);
const secondRes = await POST(secondReq);
expect(secondRes.status).toBe(201);
const secondBody = await secondRes.json();
const [secondInterest] = await db
.select()
.from(interests)
.where(eq(interests.id, secondBody.data.id));
const [secondYacht] = await db
.select()
.from(yachts)
.where(eq(yachts.id, secondInterest!.yachtId!));
expect(secondYacht!.currentOwnerId).toBe(originalCompanyId);
// Only one company row exists for that (portId, lowered name)
const allCompanies = await db.select().from(companies).where(eq(companies.portId, port.id));
const matching = allCompanies.filter((c) => c.name.toLowerCase() === companyName.toLowerCase());
expect(matching.length).toBe(1);
// Second client has its own membership in the same company
const memberships = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, originalCompanyId),
eq(companyMemberships.clientId, secondInterest!.clientId),
isNull(companyMemberships.endDate),
),
);
expect(memberships.length).toBe(1);
expect(memberships[0]!.role).toBe('employee');
});
});