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

@@ -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',