Cross-cutting i18n polish for forms across the marina + residential + company
domains. Introduces a single source of truth for country/phone/timezone/
subdivision data and replaces every nationality-as-free-text and timezone-
as-string Input with a dedicated combobox.
PR1 Countries — ALL_COUNTRY_CODES (~250 ISO-3166-1 alpha-2), Intl.DisplayNames
for localized labels, detectDefaultCountry() with navigator-region
fallback to US, CountryCombobox with regional-indicator flag glyphs +
compact mode for inline use.
PR2 Phone — libphonenumber-js wrapper (parsePhone / formatAsYouType /
callingCodeFor), PhoneInput with flag dropdown + national-format
AsYouType + paste-detect that flips the country dropdown for pasted
international strings.
PR3 Timezones — country->IANA map (250 entries, multi-zone for AU/BR/CA/CD/
ID/KZ/MN/MX/RU/US), formatTimezoneLabel ("Europe/London (UTC+1)"),
TimezoneCombobox with Suggested/All grouping driven by countryHint.
PR4 Subdivisions — wraps the iso-3166-2 npm package (~5000 ISO 3166-2
codes for every country), per-country cache, SubdivisionCombobox with
"Pick a country first" / "No regions available" empty states.
PR5 Schema deltas (migration 0015) — clients.nationality_iso, clientContacts
{value_e164, value_country}, clientAddresses {country_iso, subdivision_iso},
residentialClients {phone_e164, phone_country, nationality_iso, timezone,
place_of_residence_country_iso, subdivision_iso}, companies {incorporation_
country_iso, incorporation_subdivision_iso}, companyAddresses {country_iso,
subdivision_iso}. Plus shared zod validators (validators/i18n.ts) used
by every entity validator + route handler.
PR6 ClientForm + ClientDetail — CountryCombobox replaces nationality Input,
TimezoneCombobox replaces timezone Input (driven by nationalityIso hint),
PhoneInput conditionally rendered for phone/whatsapp contacts. Inline
editors (InlineCountryField / InlineTimezoneField / InlinePhoneField)
for the detail-page overview rows + ContactsEditor.
PR7 Residential client form + detail — phone -> PhoneInput, nationality/
timezone/place-of-residence-country/subdivision rows in both create
sheet and inline-editable detail view. Subdivision wipes when country
flips since codes are country-scoped.
PR8 Company form + detail — incorporation country -> CountryCombobox,
incorporation region -> SubdivisionCombobox in both modes.
PR9 Public inquiry endpoint — accepts pre-normalized phoneE164/phoneCountry
and i18n fields from newer website builds, server-side parsePhone()
fallback for legacy raw-international submissions. Old Nuxt builds
keep working unchanged.
Tests: 4 unit suites for the primitives (25 tests), 1 integration spec for
the public phone-normalization path (3 tests), 1 smoke spec asserting the
combobox triggers render in all three create sheets.
Test totals: vitest 713 -> 741 (+28).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.3 KiB
TypeScript
197 lines
7.3 KiB
TypeScript
import {
|
|
pgTable,
|
|
text,
|
|
boolean,
|
|
timestamp,
|
|
jsonb,
|
|
index,
|
|
uniqueIndex,
|
|
primaryKey,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { ports } from './ports';
|
|
|
|
export const clients = pgTable(
|
|
'clients',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
fullName: text('full_name').notNull(),
|
|
nationality: text('nationality'),
|
|
/** ISO-3166-1 alpha-2 nationality code. Supersedes `nationality`
|
|
* after the i18n backfill (PR10) drops the legacy column. */
|
|
nationalityIso: text('nationality_iso'),
|
|
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
|
|
preferredLanguage: text('preferred_language'),
|
|
/** IANA timezone, e.g. 'Europe/Warsaw'. Validated client + server. */
|
|
timezone: text('timezone'),
|
|
source: text('source'), // website, manual, referral, broker
|
|
sourceDetails: text('source_details'),
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_clients_port').on(table.portId),
|
|
index('idx_clients_name').on(table.portId, table.fullName),
|
|
index('idx_clients_archived').on(table.portId, table.archivedAt),
|
|
index('idx_clients_nationality_iso').on(table.nationalityIso),
|
|
],
|
|
);
|
|
|
|
export const clientContacts = pgTable(
|
|
'client_contacts',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
channel: text('channel').notNull(), // email, phone, whatsapp, other
|
|
value: text('value').notNull(),
|
|
/** E.164-normalized phone number (only set when channel='phone'/'whatsapp'). */
|
|
valueE164: text('value_e164'),
|
|
/** ISO-3166-1 alpha-2 of the country this number was parsed against. */
|
|
valueCountry: text('value_country'),
|
|
label: text('label'), // primary, secondary, work, personal, broker, assistant
|
|
isPrimary: boolean('is_primary').notNull().default(false),
|
|
notes: text('notes'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_cc_client').on(table.clientId),
|
|
index('idx_cc_email')
|
|
.on(table.channel, table.value)
|
|
.where(sql`${table.channel} = 'email'`),
|
|
index('idx_cc_phone')
|
|
.on(table.channel, table.value)
|
|
.where(sql`${table.channel} = 'phone'`),
|
|
],
|
|
);
|
|
|
|
export const clientRelationships = pgTable(
|
|
'client_relationships',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
clientAId: text('client_a_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
clientBId: text('client_b_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
relationshipType: text('relationship_type').notNull(), // referred_by, broker_for, family_member, same_vessel, custom
|
|
description: text('description'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_cr_port').on(table.portId)],
|
|
);
|
|
|
|
export const clientNotes = pgTable(
|
|
'client_notes',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
authorId: text('author_id').notNull(), // user ID
|
|
content: text('content').notNull(),
|
|
mentions: text('mentions').array(), // array of mentioned user IDs
|
|
isLocked: boolean('is_locked').notNull().default(false),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_cn_client').on(table.clientId)],
|
|
);
|
|
|
|
export const clientTags = pgTable(
|
|
'client_tags',
|
|
{
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
tagId: text('tag_id').notNull(), // references tags.id — defined later in system.ts
|
|
},
|
|
(table) => [primaryKey({ columns: [table.clientId, table.tagId] })],
|
|
);
|
|
|
|
export const clientMergeLog = pgTable(
|
|
'client_merge_log',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id),
|
|
survivingClientId: text('surviving_client_id')
|
|
.notNull()
|
|
.references(() => clients.id),
|
|
mergedClientId: text('merged_client_id').notNull(), // the client that was merged away (may no longer exist)
|
|
mergedBy: text('merged_by').notNull(), // user ID
|
|
mergeDetails: jsonb('merge_details').notNull(), // which fields were kept from which record
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [index('idx_cml_port').on(table.portId)],
|
|
);
|
|
|
|
export const clientAddresses = pgTable(
|
|
'client_addresses',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
|
label: text('label').notNull().default('Primary'),
|
|
streetAddress: text('street_address'),
|
|
city: text('city'),
|
|
stateProvince: text('state_province'),
|
|
/** ISO 3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). Optional. */
|
|
subdivisionIso: text('subdivision_iso'),
|
|
postalCode: text('postal_code'),
|
|
country: text('country'),
|
|
/** ISO-3166-1 alpha-2 country code. Supersedes `country` after backfill. */
|
|
countryIso: text('country_iso'),
|
|
isPrimary: boolean('is_primary').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_ca_client').on(table.clientId),
|
|
index('idx_ca_port').on(table.portId),
|
|
uniqueIndex('idx_ca_primary')
|
|
.on(table.clientId)
|
|
.where(sql`${table.isPrimary} = true`),
|
|
],
|
|
);
|
|
|
|
export type Client = typeof clients.$inferSelect;
|
|
export type NewClient = typeof clients.$inferInsert;
|
|
export type ClientContact = typeof clientContacts.$inferSelect;
|
|
export type NewClientContact = typeof clientContacts.$inferInsert;
|
|
export type ClientRelationship = typeof clientRelationships.$inferSelect;
|
|
export type NewClientRelationship = typeof clientRelationships.$inferInsert;
|
|
export type ClientNote = typeof clientNotes.$inferSelect;
|
|
export type NewClientNote = typeof clientNotes.$inferInsert;
|
|
export type ClientMergeLog = typeof clientMergeLog.$inferSelect;
|
|
export type NewClientMergeLog = typeof clientMergeLog.$inferInsert;
|
|
export type ClientAddress = typeof clientAddresses.$inferSelect;
|
|
export type NewClientAddress = typeof clientAddresses.$inferInsert;
|