chore(i18n): drop legacy free-text country/nationality columns

Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).

Migration 0016 drops:
  - clients.nationality
  - companies.incorporation_country
  - client_addresses.{state_province, country}
  - company_addresses.{state_province, country}

Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.

Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.

Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.

Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').

Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-28 19:00:57 +02:00
parent 31fa3d08ec
commit 27cdbcc695
30 changed files with 9959 additions and 104 deletions

View File

@@ -144,7 +144,6 @@ export async function POST(req: NextRequest) {
name: data.company.name, name: data.company.name,
legalName: data.company.legalName ?? null, legalName: data.company.legalName ?? null,
taxId: data.company.taxId ?? null, taxId: data.company.taxId ?? null,
incorporationCountry: data.company.incorporationCountry ?? null,
incorporationCountryIso: data.company.incorporationCountryIso ?? null, incorporationCountryIso: data.company.incorporationCountryIso ?? null,
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null, incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
status: 'active', status: 'active',
@@ -216,10 +215,8 @@ export async function POST(req: NextRequest) {
label: 'Primary', label: 'Primary',
streetAddress: data.address.street ?? null, streetAddress: data.address.street ?? null,
city: data.address.city ?? null, city: data.address.city ?? null,
stateProvince: data.address.stateProvince ?? null,
subdivisionIso: data.address.subdivisionIso ?? null, subdivisionIso: data.address.subdivisionIso ?? null,
postalCode: data.address.postalCode ?? null, postalCode: data.address.postalCode ?? null,
country: data.address.country ?? null,
countryIso: data.address.countryIso ?? null, countryIso: data.address.countryIso ?? null,
isPrimary: true, isPrimary: true,
}); });

View File

@@ -14,11 +14,12 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { getCountryName } from '@/lib/i18n/countries';
export interface ClientRow { export interface ClientRow {
id: string; id: string;
fullName: string; fullName: string;
nationality: string | null; nationalityIso: string | null;
source: string | null; source: string | null;
archivedAt: string | null; archivedAt: string | null;
createdAt: string; createdAt: string;
@@ -78,11 +79,14 @@ export function getClientColumns({
}, },
{ {
id: 'nationality', id: 'nationality',
accessorKey: 'nationality', accessorKey: 'nationalityIso',
header: 'Nationality', header: 'Nationality',
cell: ({ getValue }) => ( cell: ({ getValue }) => {
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span> const iso = getValue() as string | null;
), return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
);
},
}, },
{ {
id: 'source', id: 'source',

View File

@@ -12,7 +12,7 @@ interface ClientData {
id: string; id: string;
portId: string; portId: string;
fullName: string; fullName: string;
nationality: string | null; nationalityIso: string | null;
preferredContactMethod: string | null; preferredContactMethod: string | null;
preferredLanguage: string | null; preferredLanguage: string | null;
timezone: string | null; timezone: string | null;

View File

@@ -34,7 +34,6 @@ interface ClientFormProps {
client?: { client?: {
id: string; id: string;
fullName: string; fullName: string;
nationality?: string | null;
nationalityIso?: string | null; nationalityIso?: string | null;
preferredContactMethod?: string | null; preferredContactMethod?: string | null;
preferredLanguage?: string | null; preferredLanguage?: string | null;
@@ -83,7 +82,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
if (client && open) { if (client && open) {
reset({ reset({
fullName: client.fullName, fullName: client.fullName,
nationality: client.nationality ?? undefined,
nationalityIso: client.nationalityIso ?? undefined, nationalityIso: client.nationalityIso ?? undefined,
preferredContactMethod: preferredContactMethod:
(client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ?? (client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ??

View File

@@ -19,7 +19,6 @@ export interface CompanyRow {
legalName: string | null; legalName: string | null;
taxId: string | null; taxId: string | null;
registrationNumber: string | null; registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null; incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null; incorporationSubdivisionIso: string | null;
incorporationDate: string | null; incorporationDate: string | null;

View File

@@ -20,7 +20,6 @@ interface CompanyDetailHeaderCompany {
legalName: string | null; legalName: string | null;
taxId: string | null; taxId: string | null;
registrationNumber: string | null; registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null; incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null; incorporationSubdivisionIso: string | null;
incorporationDate: string | null; incorporationDate: string | null;
@@ -132,7 +131,6 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
legalName: company.legalName, legalName: company.legalName,
taxId: company.taxId, taxId: company.taxId,
registrationNumber: company.registrationNumber, registrationNumber: company.registrationNumber,
incorporationCountry: company.incorporationCountry,
incorporationCountryIso: company.incorporationCountryIso, incorporationCountryIso: company.incorporationCountryIso,
incorporationSubdivisionIso: company.incorporationSubdivisionIso, incorporationSubdivisionIso: company.incorporationSubdivisionIso,
incorporationDate: company.incorporationDate, incorporationDate: company.incorporationDate,

View File

@@ -16,7 +16,6 @@ export interface CompanyData {
legalName: string | null; legalName: string | null;
taxId: string | null; taxId: string | null;
registrationNumber: string | null; registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null; incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null; incorporationSubdivisionIso: string | null;
incorporationDate: string | null; incorporationDate: string | null;

View File

@@ -41,7 +41,6 @@ interface CompanyFormProps {
legalName: string | null; legalName: string | null;
taxId: string | null; taxId: string | null;
registrationNumber: string | null; registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null; incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null; incorporationSubdivisionIso: string | null;
incorporationDate: string | null; incorporationDate: string | null;
@@ -83,7 +82,6 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
legalName: company.legalName ?? undefined, legalName: company.legalName ?? undefined,
taxId: company.taxId ?? undefined, taxId: company.taxId ?? undefined,
registrationNumber: company.registrationNumber ?? undefined, registrationNumber: company.registrationNumber ?? undefined,
incorporationCountry: company.incorporationCountry ?? undefined,
incorporationCountryIso: company.incorporationCountryIso ?? undefined, incorporationCountryIso: company.incorporationCountryIso ?? undefined,
incorporationSubdivisionIso: company.incorporationSubdivisionIso ?? undefined, incorporationSubdivisionIso: company.incorporationSubdivisionIso ?? undefined,
incorporationDate: company.incorporationDate incorporationDate: company.incorporationDate

View File

@@ -145,7 +145,6 @@ export function CompanyList() {
legalName: editCompany.legalName, legalName: editCompany.legalName,
taxId: editCompany.taxId, taxId: editCompany.taxId,
registrationNumber: editCompany.registrationNumber, registrationNumber: editCompany.registrationNumber,
incorporationCountry: editCompany.incorporationCountry,
incorporationCountryIso: editCompany.incorporationCountryIso, incorporationCountryIso: editCompany.incorporationCountryIso,
incorporationSubdivisionIso: editCompany.incorporationSubdivisionIso, incorporationSubdivisionIso: editCompany.incorporationSubdivisionIso,
incorporationDate: editCompany.incorporationDate, incorporationDate: editCompany.incorporationDate,

View File

@@ -38,7 +38,6 @@ interface CompanyTabsCompany {
legalName: string | null; legalName: string | null;
taxId: string | null; taxId: string | null;
registrationNumber: string | null; registrationNumber: string | null;
incorporationCountry: string | null;
incorporationCountryIso: string | null; incorporationCountryIso: string | null;
incorporationSubdivisionIso: string | null; incorporationSubdivisionIso: string | null;
incorporationDate: string | null; incorporationDate: string | null;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "client_addresses" DROP COLUMN "state_province";--> statement-breakpoint
ALTER TABLE "client_addresses" DROP COLUMN "country";--> statement-breakpoint
ALTER TABLE "clients" DROP COLUMN "nationality";--> statement-breakpoint
ALTER TABLE "companies" DROP COLUMN "incorporation_country";--> statement-breakpoint
ALTER TABLE "company_addresses" DROP COLUMN "state_province";--> statement-breakpoint
ALTER TABLE "company_addresses" DROP COLUMN "country";

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,13 @@
"when": 1777391373291, "when": 1777391373291,
"tag": "0015_i18n_columns", "tag": "0015_i18n_columns",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1777395538988,
"tag": "0016_magical_spyke",
"breakpoints": true
} }
] ]
} }

View File

@@ -21,9 +21,7 @@ export const clients = pgTable(
.notNull() .notNull()
.references(() => ports.id), .references(() => ports.id),
fullName: text('full_name').notNull(), fullName: text('full_name').notNull(),
nationality: text('nationality'), /** ISO-3166-1 alpha-2 nationality code. */
/** ISO-3166-1 alpha-2 nationality code. Supersedes `nationality`
* after the i18n backfill (PR10) drops the legacy column. */
nationalityIso: text('nationality_iso'), nationalityIso: text('nationality_iso'),
preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp preferredContactMethod: text('preferred_contact_method'), // email, phone, whatsapp
preferredLanguage: text('preferred_language'), preferredLanguage: text('preferred_language'),
@@ -162,12 +160,10 @@ export const clientAddresses = pgTable(
label: text('label').notNull().default('Primary'), label: text('label').notNull().default('Primary'),
streetAddress: text('street_address'), streetAddress: text('street_address'),
city: text('city'), city: text('city'),
stateProvince: text('state_province'),
/** ISO 3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). Optional. */ /** ISO 3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). Optional. */
subdivisionIso: text('subdivision_iso'), subdivisionIso: text('subdivision_iso'),
postalCode: text('postal_code'), postalCode: text('postal_code'),
country: text('country'), /** ISO-3166-1 alpha-2 country code. */
/** ISO-3166-1 alpha-2 country code. Supersedes `country` after backfill. */
countryIso: text('country_iso'), countryIso: text('country_iso'),
isPrimary: boolean('is_primary').notNull().default(true), isPrimary: boolean('is_primary').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -24,9 +24,7 @@ export const companies = pgTable(
legalName: text('legal_name'), legalName: text('legal_name'),
taxId: text('tax_id'), taxId: text('tax_id'),
registrationNumber: text('registration_number'), registrationNumber: text('registration_number'),
incorporationCountry: text('incorporation_country'), /** ISO-3166-1 alpha-2 country of incorporation. */
/** ISO-3166-1 alpha-2 country of incorporation. Replaces the
* free-text `incorporation_country` after the i18n backfill. */
incorporationCountryIso: text('incorporation_country_iso'), incorporationCountryIso: text('incorporation_country_iso'),
/** ISO 3166-2 subdivision (state/province) of incorporation. Optional. */ /** ISO 3166-2 subdivision (state/province) of incorporation. Optional. */
incorporationSubdivisionIso: text('incorporation_subdivision_iso'), incorporationSubdivisionIso: text('incorporation_subdivision_iso'),
@@ -93,12 +91,10 @@ export const companyAddresses = pgTable(
label: text('label').notNull().default('Primary'), label: text('label').notNull().default('Primary'),
streetAddress: text('street_address'), streetAddress: text('street_address'),
city: text('city'), city: text('city'),
stateProvince: text('state_province'),
/** ISO 3166-2 subdivision code. Optional. */ /** ISO 3166-2 subdivision code. Optional. */
subdivisionIso: text('subdivision_iso'), subdivisionIso: text('subdivision_iso'),
postalCode: text('postal_code'), postalCode: text('postal_code'),
country: text('country'), /** ISO-3166-1 alpha-2 country code. */
/** ISO-3166-1 alpha-2 country code. Supersedes `country` after backfill. */
countryIso: text('country_iso'), countryIso: text('country_iso'),
isPrimary: boolean('is_primary').notNull().default(true), isPrimary: boolean('is_primary').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -230,7 +230,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
legalName: 'Aegean Holdings Ltd.', legalName: 'Aegean Holdings Ltd.',
taxId: `AH-${portSlug}-001`, taxId: `AH-${portSlug}-001`,
registrationNumber: 'AH-2019-8842', registrationNumber: 'AH-2019-8842',
incorporationCountry: 'Greece', incorporationCountryIso: 'GR',
incorporationDate: new Date('2019-03-14'), incorporationDate: new Date('2019-03-14'),
status: 'active', status: 'active',
billingEmail: `billing@aegean-holdings.example`, billingEmail: `billing@aegean-holdings.example`,
@@ -242,7 +242,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
legalName: 'Blue Seas Marine S.A.', legalName: 'Blue Seas Marine S.A.',
taxId: `BSM-${portSlug}-002`, taxId: `BSM-${portSlug}-002`,
registrationNumber: 'BSM-2021-3310', registrationNumber: 'BSM-2021-3310',
incorporationCountry: 'Monaco', incorporationCountryIso: 'MC',
incorporationDate: new Date('2021-07-02'), incorporationDate: new Date('2021-07-02'),
status: 'active', status: 'active',
billingEmail: `accounts@blueseas-marine.example`, billingEmail: `accounts@blueseas-marine.example`,
@@ -254,7 +254,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
legalName: 'Phantom Maritime SA', legalName: 'Phantom Maritime SA',
taxId: `PHT-${portSlug}-003`, taxId: `PHT-${portSlug}-003`,
registrationNumber: 'PHT-2017-7001', registrationNumber: 'PHT-2017-7001',
incorporationCountry: 'Panama', incorporationCountryIso: 'PA',
incorporationDate: new Date('2017-11-20'), incorporationDate: new Date('2017-11-20'),
status: 'dissolved', status: 'dissolved',
billingEmail: null, billingEmail: null,
@@ -276,9 +276,9 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
label: 'Head Office', label: 'Head Office',
streetAddress: '14 Mikonou Avenue', streetAddress: '14 Mikonou Avenue',
city: 'Athens', city: 'Athens',
stateProvince: 'Attica', subdivisionIso: 'GR-A',
postalCode: '10558', postalCode: '10558',
country: 'Greece', countryIso: 'GR',
isPrimary: true, isPrimary: true,
}, },
{ {
@@ -287,9 +287,9 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
label: 'Registered Office', label: 'Registered Office',
streetAddress: '3 Boulevard des Moulins', streetAddress: '3 Boulevard des Moulins',
city: 'Monte Carlo', city: 'Monte Carlo',
stateProvince: null, subdivisionIso: null,
postalCode: 'MC-98000', postalCode: 'MC-98000',
country: 'Monaco', countryIso: 'MC',
isPrimary: true, isPrimary: true,
}, },
{ {
@@ -298,9 +298,9 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
label: 'Former Office', label: 'Former Office',
streetAddress: 'Calle 50, Torre Global, Piso 20', streetAddress: 'Calle 50, Torre Global, Piso 20',
city: 'Panama City', city: 'Panama City',
stateProvince: null, subdivisionIso: null,
postalCode: '0801', postalCode: '0801',
country: 'Panama', countryIso: 'PA',
isPrimary: true, isPrimary: true,
}, },
]); ]);
@@ -313,96 +313,96 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
// 7 → Phantom SA (ended membership) // 7 → Phantom SA (ended membership)
const CLIENT_SPECS: Array<{ const CLIENT_SPECS: Array<{
fullName: string; fullName: string;
nationality: string; nationalityIso: string;
email: string; email: string;
phone: string; phone: string;
whatsapp?: string; whatsapp?: string;
city: string; city: string;
country: string; countryIso: string;
postalCode: string; postalCode: string;
street: string; street: string;
}> = [ }> = [
{ {
fullName: 'Helena Marsh', fullName: 'Helena Marsh',
nationality: 'British', nationalityIso: 'GB',
email: 'helena.marsh@example.com', email: 'helena.marsh@example.com',
phone: '+44 20 7946 0001', phone: '+44 20 7946 0001',
whatsapp: '+44 7700 900001', whatsapp: '+44 7700 900001',
city: 'London', city: 'London',
country: 'United Kingdom', countryIso: 'GB',
postalCode: 'SW1A 1AA', postalCode: 'SW1A 1AA',
street: '22 Belgrave Square', street: '22 Belgrave Square',
}, },
{ {
fullName: 'Marcus Laurent', fullName: 'Marcus Laurent',
nationality: 'French', nationalityIso: 'FR',
email: 'marcus.laurent@example.com', email: 'marcus.laurent@example.com',
phone: '+33 4 93 00 0002', phone: '+33 4 93 00 0002',
city: 'Nice', city: 'Nice',
country: 'France', countryIso: 'FR',
postalCode: '06300', postalCode: '06300',
street: '8 Promenade des Anglais', street: '8 Promenade des Anglais',
}, },
{ {
fullName: 'Sofia Reyes', fullName: 'Sofia Reyes',
nationality: 'Spanish', nationalityIso: 'ES',
email: 'sofia.reyes@example.com', email: 'sofia.reyes@example.com',
phone: '+34 971 000 003', phone: '+34 971 000 003',
whatsapp: '+34 666 000 003', whatsapp: '+34 666 000 003',
city: 'Palma', city: 'Palma',
country: 'Spain', countryIso: 'ES',
postalCode: '07012', postalCode: '07012',
street: 'Passeig Marítim 12', street: 'Passeig Marítim 12',
}, },
{ {
fullName: 'Dimitrios Andreadis', fullName: 'Dimitrios Andreadis',
nationality: 'Greek', nationalityIso: 'GR',
email: 'd.andreadis@aegean-holdings.example', email: 'd.andreadis@aegean-holdings.example',
phone: '+30 210 000 0004', phone: '+30 210 000 0004',
city: 'Athens', city: 'Athens',
country: 'Greece', countryIso: 'GR',
postalCode: '10558', postalCode: '10558',
street: '14 Mikonou Avenue', street: '14 Mikonou Avenue',
}, },
{ {
fullName: 'Katerina Papadakis', fullName: 'Katerina Papadakis',
nationality: 'Greek', nationalityIso: 'GR',
email: 'k.papadakis@aegean-holdings.example', email: 'k.papadakis@aegean-holdings.example',
phone: '+30 210 000 0005', phone: '+30 210 000 0005',
whatsapp: '+30 694 000 0005', whatsapp: '+30 694 000 0005',
city: 'Athens', city: 'Athens',
country: 'Greece', countryIso: 'GR',
postalCode: '10558', postalCode: '10558',
street: '14 Mikonou Avenue', street: '14 Mikonou Avenue',
}, },
{ {
fullName: 'Jonas Lindqvist', fullName: 'Jonas Lindqvist',
nationality: 'Swedish', nationalityIso: 'SE',
email: 'jonas.lindqvist@example.com', email: 'jonas.lindqvist@example.com',
phone: '+46 8 000 0006', phone: '+46 8 000 0006',
city: 'Stockholm', city: 'Stockholm',
country: 'Sweden', countryIso: 'SE',
postalCode: '11129', postalCode: '11129',
street: 'Strandvägen 47', street: 'Strandvägen 47',
}, },
{ {
fullName: 'Isabella Conti', fullName: 'Isabella Conti',
nationality: 'Italian', nationalityIso: 'IT',
email: 'isabella.conti@example.com', email: 'isabella.conti@example.com',
phone: '+39 010 000 0007', phone: '+39 010 000 0007',
whatsapp: '+39 333 000 0007', whatsapp: '+39 333 000 0007',
city: 'Genoa', city: 'Genoa',
country: 'Italy', countryIso: 'IT',
postalCode: '16124', postalCode: '16124',
street: 'Via Garibaldi 9', street: 'Via Garibaldi 9',
}, },
{ {
fullName: 'Raymond Osei', fullName: 'Raymond Osei',
nationality: 'Ghanaian', nationalityIso: 'GH',
email: 'raymond.osei@example.com', email: 'raymond.osei@example.com',
phone: '+233 30 000 0008', phone: '+233 30 000 0008',
city: 'Accra', city: 'Accra',
country: 'Ghana', countryIso: 'GH',
postalCode: 'GA-183-1090', postalCode: 'GA-183-1090',
street: '21 Independence Ave', street: '21 Independence Ave',
}, },
@@ -414,7 +414,7 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
CLIENT_SPECS.map((c) => ({ CLIENT_SPECS.map((c) => ({
portId, portId,
fullName: c.fullName, fullName: c.fullName,
nationality: c.nationality, nationalityIso: c.nationalityIso,
preferredContactMethod: 'email' as const, preferredContactMethod: 'email' as const,
preferredLanguage: 'en', preferredLanguage: 'en',
source: 'referral' as const, source: 'referral' as const,
@@ -462,9 +462,9 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
label: 'Primary', label: 'Primary',
streetAddress: c.street, streetAddress: c.street,
city: c.city, city: c.city,
stateProvince: null, subdivisionIso: null,
postalCode: c.postalCode, postalCode: c.postalCode,
country: c.country, countryIso: c.countryIso,
isPrimary: true, isPrimary: true,
})), })),
); );

View File

@@ -39,7 +39,9 @@ export async function listClients(portId: string, query: ListClientsInput) {
filters.push(eq(clients.source, source)); filters.push(eq(clients.source, source));
} }
if (nationality) { if (nationality) {
filters.push(ilike(clients.nationality, `%${nationality}%`)); // Filter accepts an ISO-3166-1 alpha-2 code; legacy free-text matching is
// gone after the i18n column drop.
filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
} }
if (tagIds && tagIds.length > 0) { if (tagIds && tagIds.length > 0) {
const clientsWithTags = await db const clientsWithTags = await db

View File

@@ -64,7 +64,8 @@ export async function createCompany(portId: string, data: CreateCompanyInput, me
legalName: data.legalName ?? null, legalName: data.legalName ?? null,
taxId: data.taxId ?? null, taxId: data.taxId ?? null,
registrationNumber: data.registrationNumber ?? null, registrationNumber: data.registrationNumber ?? null,
incorporationCountry: data.incorporationCountry ?? null, incorporationCountryIso: data.incorporationCountryIso ?? null,
incorporationSubdivisionIso: data.incorporationSubdivisionIso ?? null,
incorporationDate: data.incorporationDate ?? null, incorporationDate: data.incorporationDate ?? null,
status: data.status ?? 'active', status: data.status ?? 'active',
billingEmail: data.billingEmail ?? null, billingEmail: data.billingEmail ?? null,

View File

@@ -17,6 +17,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate'; import { generatePdf } from '@/lib/pdf/generate';
import { getCountryName } from '@/lib/i18n/countries';
import { import {
createDocument as documensoCreate, createDocument as documensoCreate,
sendDocument as documensoSend, sendDocument as documensoSend,
@@ -344,7 +345,9 @@ export async function resolveTemplate(
tokenMap['{{client.fullName}}'] = client.fullName ?? ''; tokenMap['{{client.fullName}}'] = client.fullName ?? '';
tokenMap['{{client.email}}'] = emailContact?.value ?? ''; tokenMap['{{client.email}}'] = emailContact?.value ?? '';
tokenMap['{{client.phone}}'] = phoneContact?.value ?? ''; tokenMap['{{client.phone}}'] = phoneContact?.value ?? '';
tokenMap['{{client.nationality}}'] = client.nationality ?? ''; tokenMap['{{client.nationality}}'] = client.nationalityIso
? getCountryName(client.nationalityIso, 'en')
: '';
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import { companies, companyAddresses } from '@/lib/db/schema/companies';
import { interests } from '@/lib/db/schema/interests'; import { interests } from '@/lib/db/schema/interests';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries';
import { NotFoundError, ValidationError } from '@/lib/errors'; import { NotFoundError, ValidationError } from '@/lib/errors';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -136,12 +137,13 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
contactRows.find((c) => c.channel === 'phone') ?? contactRows.find((c) => c.channel === 'phone') ??
contactRows.find((c) => c.channel === 'whatsapp'); contactRows.find((c) => c.channel === 'whatsapp');
// 6. Primary address. // 6. Primary address. Country is rendered as the localized name (English by
// default for documents) from the ISO code.
const [primaryAddress] = await db const [primaryAddress] = await db
.select({ .select({
streetAddress: clientAddresses.streetAddress, streetAddress: clientAddresses.streetAddress,
city: clientAddresses.city, city: clientAddresses.city,
country: clientAddresses.country, countryIso: clientAddresses.countryIso,
}) })
.from(clientAddresses) .from(clientAddresses)
.where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true))) .where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)))
@@ -151,7 +153,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
? { ? {
street: primaryAddress.streetAddress ?? '', street: primaryAddress.streetAddress ?? '',
city: primaryAddress.city ?? '', city: primaryAddress.city ?? '',
country: primaryAddress.country ?? '', country: primaryAddress.countryIso ? getCountryName(primaryAddress.countryIso, 'en') : '',
} }
: null; : null;
@@ -185,7 +187,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
.select({ .select({
streetAddress: companyAddresses.streetAddress, streetAddress: companyAddresses.streetAddress,
city: companyAddresses.city, city: companyAddresses.city,
country: companyAddresses.country, countryIso: companyAddresses.countryIso,
}) })
.from(companyAddresses) .from(companyAddresses)
.where(and(eq(companyAddresses.companyId, company.id), eq(companyAddresses.isPrimary, true))) .where(and(eq(companyAddresses.companyId, company.id), eq(companyAddresses.isPrimary, true)))
@@ -195,7 +197,9 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
? [ ? [
companyPrimaryAddress.streetAddress, companyPrimaryAddress.streetAddress,
companyPrimaryAddress.city, companyPrimaryAddress.city,
companyPrimaryAddress.country, companyPrimaryAddress.countryIso
? getCountryName(companyPrimaryAddress.countryIso, 'en')
: null,
] ]
.filter((s): s is string => Boolean(s)) .filter((s): s is string => Boolean(s))
.join(', ') || null .join(', ') || null
@@ -219,7 +223,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
return { return {
client: { client: {
fullName: client.fullName, fullName: client.fullName,
nationality: client.nationality, nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
primaryEmail: firstEmail?.value ?? null, primaryEmail: firstEmail?.value ?? null,
primaryPhone: firstPhone?.value ?? null, primaryPhone: firstPhone?.value ?? null,
address: clientAddress, address: clientAddress,

View File

@@ -13,6 +13,8 @@ import { createAuditLog } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff'; import { diffEntity } from '@/lib/entity-diff';
import { withTransaction } from '@/lib/db/utils'; import { withTransaction } from '@/lib/db/utils';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { getCountryName } from '@/lib/i18n/countries';
import { getSubdivisionName } from '@/lib/i18n/subdivisions';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { generatePdf } from '@/lib/pdf/generate'; import { generatePdf } from '@/lib/pdf/generate';
@@ -99,9 +101,9 @@ async function resolveBillingEntity(
? [ ? [
addressRow.streetAddress, addressRow.streetAddress,
addressRow.city, addressRow.city,
addressRow.stateProvince, addressRow.subdivisionIso ? getSubdivisionName(addressRow.subdivisionIso) : null,
addressRow.postalCode, addressRow.postalCode,
addressRow.country, addressRow.countryIso ? getCountryName(addressRow.countryIso, 'en') : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(', ') .join(', ')
@@ -126,9 +128,9 @@ async function resolveBillingEntity(
? [ ? [
addressRow.streetAddress, addressRow.streetAddress,
addressRow.city, addressRow.city,
addressRow.stateProvince, addressRow.subdivisionIso ? getSubdivisionName(addressRow.subdivisionIso) : null,
addressRow.postalCode, addressRow.postalCode,
addressRow.country, addressRow.countryIso ? getCountryName(addressRow.countryIso, 'en') : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(', ') .join(', ')

View File

@@ -11,6 +11,7 @@ import { yachts } from '@/lib/db/schema/yachts';
import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { getPresignedUrl } from '@/lib/minio'; import { getPresignedUrl } from '@/lib/minio';
import { getCountryName } from '@/lib/i18n/countries';
// ─── Dashboard ──────────────────────────────────────────────────────────────── // ─── Dashboard ────────────────────────────────────────────────────────────────
@@ -82,7 +83,7 @@ export async function getPortalDashboard(
client: { client: {
id: client.id, id: client.id,
fullName: client.fullName, fullName: client.fullName,
nationality: client.nationality ?? null, nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
}, },
port: { port: {
name: port.name, name: port.name,

View File

@@ -6,6 +6,7 @@ import { berthReservations } from '@/lib/db/schema/reservations';
import { clients } from '@/lib/db/schema/clients'; import { clients } from '@/lib/db/schema/clients';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries';
import { NotFoundError } from '@/lib/errors'; import { NotFoundError } from '@/lib/errors';
export type ReservationAgreementContext = { export type ReservationAgreementContext = {
@@ -100,7 +101,7 @@ export async function buildReservationAgreementContext(
client: { client: {
id: client.id, id: client.id,
fullName: client.fullName, fullName: client.fullName,
nationality: client.nationality, nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
}, },
yacht: { yacht: {
id: yacht.id, id: yacht.id,

View File

@@ -26,8 +26,6 @@ export const contactSchema = z.object({
export const createClientSchema = z.object({ export const createClientSchema = z.object({
fullName: z.string().min(1).max(200), fullName: z.string().min(1).max(200),
contacts: z.array(contactSchema).min(1, 'At least one contact is required'), contacts: z.array(contactSchema).min(1, 'At least one contact is required'),
/** Legacy free-text nationality. Kept for backfill only — new edits write `nationalityIso`. */
nationality: z.string().optional(),
/** ISO-3166-1 alpha-2 nationality code. */ /** ISO-3166-1 alpha-2 nationality code. */
nationalityIso: optionalCountryIsoSchema.optional(), nationalityIso: optionalCountryIsoSchema.optional(),
preferredContactMethod: z.enum(['email', 'phone', 'whatsapp']).optional(), preferredContactMethod: z.enum(['email', 'phone', 'whatsapp']).optional(),

View File

@@ -7,8 +7,6 @@ export const createCompanySchema = z.object({
legalName: z.string().optional(), legalName: z.string().optional(),
taxId: z.string().optional(), taxId: z.string().optional(),
registrationNumber: z.string().optional(), registrationNumber: z.string().optional(),
/** Legacy free-text. New writes use `incorporationCountryIso`. */
incorporationCountry: z.string().optional(),
/** ISO-3166-1 alpha-2 country of incorporation. */ /** ISO-3166-1 alpha-2 country of incorporation. */
incorporationCountryIso: optionalCountryIsoSchema.optional(), incorporationCountryIso: optionalCountryIsoSchema.optional(),
/** ISO 3166-2 state/province of incorporation. */ /** ISO 3166-2 state/province of incorporation. */

View File

@@ -74,13 +74,9 @@ export const generateRecommendationsSchema = z.object({
const addressSchema = z.object({ const addressSchema = z.object({
street: z.string().max(500).optional(), street: z.string().max(500).optional(),
city: z.string().max(200).optional(), city: z.string().max(200).optional(),
/** Legacy free-text. New writes use `subdivisionIso`. */
stateProvince: z.string().max(200).optional(),
/** ISO 3166-2 subdivision code (e.g. 'PL-MZ'). */ /** ISO 3166-2 subdivision code (e.g. 'PL-MZ'). */
subdivisionIso: optionalSubdivisionIsoSchema.optional(), subdivisionIso: optionalSubdivisionIsoSchema.optional(),
postalCode: z.string().max(50).optional(), postalCode: z.string().max(50).optional(),
/** Legacy free-text. New writes use `countryIso`. */
country: z.string().max(100).optional(),
/** ISO-3166-1 alpha-2 country code. */ /** ISO-3166-1 alpha-2 country code. */
countryIso: optionalCountryIsoSchema.optional(), countryIso: optionalCountryIsoSchema.optional(),
}); });
@@ -105,8 +101,6 @@ const publicCompanySchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
legalName: z.string().max(200).optional(), legalName: z.string().max(200).optional(),
taxId: z.string().max(100).optional(), taxId: z.string().max(100).optional(),
/** Legacy free-text. New website builds should send `incorporationCountryIso`. */
incorporationCountry: z.string().max(100).optional(),
/** ISO-3166-1 alpha-2 country of incorporation. */ /** ISO-3166-1 alpha-2 country of incorporation. */
incorporationCountryIso: optionalCountryIsoSchema.optional(), incorporationCountryIso: optionalCountryIsoSchema.optional(),
/** ISO 3166-2 state/province of incorporation. */ /** ISO 3166-2 state/province of incorporation. */

View File

@@ -126,7 +126,7 @@ describe('resolveTemplate — EOI scope tokens', () => {
const port = await makePort(); const port = await makePort();
const client = await makeClient({ const client = await makeClient({
portId: port.id, portId: port.id,
overrides: { fullName: 'Alice Client', nationality: 'US', source: 'referral' }, overrides: { fullName: 'Alice Client', nationalityIso: 'US', source: 'referral' },
}); });
await db.insert(clientContacts).values([ await db.insert(clientContacts).values([
{ clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true }, { clientId: client.id, channel: 'email', value: 'alice@example.com', isPrimary: true },
@@ -137,7 +137,7 @@ describe('resolveTemplate — EOI scope tokens', () => {
portId: port.id, portId: port.id,
streetAddress: '1 Main St', streetAddress: '1 Main St',
city: 'Town', city: 'Town',
country: 'US', countryIso: 'US',
isPrimary: true, isPrimary: true,
}); });
@@ -321,7 +321,7 @@ describe('resolveTemplate — legacy fallback (no interestId)', () => {
const port = await makePort(); const port = await makePort();
const client = await makeClient({ const client = await makeClient({
portId: port.id, portId: port.id,
overrides: { fullName: 'Carol NoInterest', nationality: 'UK', source: 'website' }, overrides: { fullName: 'Carol NoInterest', nationalityIso: 'GB', source: 'website' },
}); });
await db.insert(clientContacts).values({ await db.insert(clientContacts).values({
clientId: client.id, clientId: client.id,
@@ -349,7 +349,8 @@ describe('resolveTemplate — legacy fallback (no interestId)', () => {
expect(resolved).toContain('Hello Carol NoInterest'); expect(resolved).toContain('Hello Carol NoInterest');
expect(resolved).toContain('carol@example.com'); expect(resolved).toContain('carol@example.com');
expect(resolved).toContain('from UK'); // Nationality renders the localized name from the ISO code (GB -> United Kingdom).
expect(resolved).toContain('from United Kingdom');
expect(resolved).toContain('src=website'); expect(resolved).toContain('src=website');
}); });

View File

@@ -108,7 +108,7 @@ beforeAll(async () => {
const port = await makePort(); const port = await makePort();
const client = await makeClient({ const client = await makeClient({
portId: port.id, portId: port.id,
overrides: { fullName: 'Dual Path Client', nationality: 'US' }, overrides: { fullName: 'Dual Path Client', nationalityIso: 'US' },
}); });
await db.insert(clientContacts).values({ await db.insert(clientContacts).values({
clientId: client.id, clientId: client.id,
@@ -121,7 +121,7 @@ beforeAll(async () => {
portId: port.id, portId: port.id,
streetAddress: '1 Wharf Rd', streetAddress: '1 Wharf Rd',
city: 'Harbor', city: 'Harbor',
country: 'US', countryIso: 'US',
isPrimary: true, isPrimary: true,
}); });

View File

@@ -161,9 +161,9 @@ describe('invoices.service — billing entity', () => {
label: 'Primary', label: 'Primary',
streetAddress: '1 Pier Road', streetAddress: '1 Pier Road',
city: 'Harbor City', city: 'Harbor City',
stateProvince: 'CA', subdivisionIso: 'US-CA',
postalCode: '90000', postalCode: '90000',
country: 'USA', countryIso: 'US',
isPrimary: true, isPrimary: true,
}); });
@@ -180,7 +180,10 @@ describe('invoices.service — billing entity', () => {
); );
expect(invoice.billingEmail).toBe('bob@example.com'); expect(invoice.billingEmail).toBe('bob@example.com');
expect(invoice.billingAddress).toBe('1 Pier Road, Harbor City, CA, 90000, USA'); // Address is rendered using ISO->name lookup (US-CA -> California, US -> United States).
expect(invoice.billingAddress).toBe(
'1 Pier Road, Harbor City, California, 90000, United States',
);
}); });
it('allows caller to override billingEmail and billingAddress', async () => { it('allows caller to override billingEmail and billingAddress', async () => {
@@ -196,9 +199,9 @@ describe('invoices.service — billing entity', () => {
label: 'Primary', label: 'Primary',
streetAddress: '2 Ocean Blvd', streetAddress: '2 Ocean Blvd',
city: 'Portville', city: 'Portville',
stateProvince: 'FL', subdivisionIso: 'US-FL',
postalCode: '33101', postalCode: '33101',
country: 'USA', countryIso: 'US',
isPrimary: true, isPrimary: true,
}); });

View File

@@ -35,7 +35,7 @@ describe('buildEoiContext', () => {
const port = await makePort(); const port = await makePort();
const client = await makeClient({ const client = await makeClient({
portId: port.id, portId: port.id,
overrides: { fullName: 'Alice Test', nationality: 'US' }, overrides: { fullName: 'Alice Test', nationalityIso: 'US' },
}); });
// Insert contacts. // Insert contacts.
@@ -60,7 +60,7 @@ describe('buildEoiContext', () => {
portId: port.id, portId: port.id,
streetAddress: '1 Harbour Way', streetAddress: '1 Harbour Way',
city: 'Anguilla', city: 'Anguilla',
country: 'AI', countryIso: 'AI',
isPrimary: true, isPrimary: true,
}); });
@@ -94,15 +94,16 @@ describe('buildEoiContext', () => {
const ctx = await buildEoiContext(interest.id, port.id); const ctx = await buildEoiContext(interest.id, port.id);
// Client assertions. // Client assertions. Nationality + address country are rendered as
// localized names (Intl.DisplayNames) from the ISO codes.
expect(ctx.client.fullName).toBe('Alice Test'); expect(ctx.client.fullName).toBe('Alice Test');
expect(ctx.client.nationality).toBe('US'); expect(ctx.client.nationality).toBe('United States');
expect(ctx.client.primaryEmail).toBe('alice@example.com'); expect(ctx.client.primaryEmail).toBe('alice@example.com');
expect(ctx.client.primaryPhone).toBe('+1-555-1234'); expect(ctx.client.primaryPhone).toBe('+1-555-1234');
expect(ctx.client.address).toEqual({ expect(ctx.client.address).toEqual({
street: '1 Harbour Way', street: '1 Harbour Way',
city: 'Anguilla', city: 'Anguilla',
country: 'AI', country: 'Anguilla',
}); });
// Yacht assertions. // Yacht assertions.
@@ -181,7 +182,7 @@ describe('buildEoiContext', () => {
portId: port.id, portId: port.id,
streetAddress: '99 Commerce St', streetAddress: '99 Commerce St',
city: 'Valley', city: 'Valley',
country: 'AI', countryIso: 'AI',
isPrimary: true, isPrimary: true,
}); });
@@ -206,7 +207,8 @@ describe('buildEoiContext', () => {
expect(ctx.company!.billingAddress).not.toBeNull(); expect(ctx.company!.billingAddress).not.toBeNull();
expect(ctx.company!.billingAddress).toContain('99 Commerce St'); expect(ctx.company!.billingAddress).toContain('99 Commerce St');
expect(ctx.company!.billingAddress).toContain('Valley'); expect(ctx.company!.billingAddress).toContain('Valley');
expect(ctx.company!.billingAddress).toContain('AI'); // Country is rendered as the localized name (AI -> Anguilla).
expect(ctx.company!.billingAddress).toContain('Anguilla');
}); });
it('throws ValidationError when interest has no yacht', async () => { it('throws ValidationError when interest has no yacht', async () => {