From 16d98d630e1fde512f64bf5eaf44200971e3ddb4 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 28 Apr 2026 18:13:08 +0200 Subject: [PATCH] feat(i18n): country/phone/timezone/subdivision primitives + form wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- package.json | 3 + pnpm-lock.yaml | 24 + .../api/public/residential-inquiries/route.ts | 18 + .../[id]/contacts/[contactId]/route.ts | 21 +- src/app/api/v1/clients/[id]/contacts/route.ts | 3 + src/components/clients/client-form.tsx | 62 +- src/components/clients/client-tabs.tsx | 24 +- src/components/clients/contacts-editor.tsx | 137 +- src/components/companies/company-columns.tsx | 2 + .../companies/company-detail-header.tsx | 4 + src/components/companies/company-detail.tsx | 2 + src/components/companies/company-form.tsx | 26 +- src/components/companies/company-list.tsx | 2 + src/components/companies/company-tabs.tsx | 30 +- .../residential/residential-client-detail.tsx | 61 +- .../residential/residential-clients-list.tsx | 86 +- src/components/shared/country-combobox.tsx | 153 + .../shared/inline-country-field.tsx | 92 + src/components/shared/inline-phone-field.tsx | 130 + .../shared/inline-timezone-field.tsx | 88 + src/components/shared/phone-input.tsx | 144 + .../shared/subdivision-combobox.tsx | 124 + src/components/shared/timezone-combobox.tsx | 150 + src/lib/db/migrations/0015_i18n_columns.sql | 16 + src/lib/db/migrations/meta/0015_snapshot.json | 9885 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema/clients.ts | 13 + src/lib/db/schema/companies.ts | 9 + src/lib/db/schema/residential.ts | 14 + src/lib/i18n/countries.ts | 311 + src/lib/i18n/phone.ts | 80 + src/lib/i18n/subdivisions.ts | 79 + src/lib/i18n/timezones.ts | 411 + src/lib/services/clients.service.ts | 12 +- src/lib/validators/clients.ts | 15 +- src/lib/validators/companies.ts | 6 + src/lib/validators/i18n.ts | 78 + src/lib/validators/residential.ts | 35 + tests/e2e/smoke/31-i18n-form-fields.spec.ts | 65 + .../public-residential-inquiry.test.ts | 138 + tests/unit/i18n-countries.test.ts | 87 + tests/unit/i18n-phone.test.ts | 60 + tests/unit/i18n-subdivisions.test.ts | 57 + tests/unit/i18n-timezones.test.ts | 71 + 44 files changed, 12768 insertions(+), 67 deletions(-) create mode 100644 src/components/shared/country-combobox.tsx create mode 100644 src/components/shared/inline-country-field.tsx create mode 100644 src/components/shared/inline-phone-field.tsx create mode 100644 src/components/shared/inline-timezone-field.tsx create mode 100644 src/components/shared/phone-input.tsx create mode 100644 src/components/shared/subdivision-combobox.tsx create mode 100644 src/components/shared/timezone-combobox.tsx create mode 100644 src/lib/db/migrations/0015_i18n_columns.sql create mode 100644 src/lib/db/migrations/meta/0015_snapshot.json create mode 100644 src/lib/i18n/countries.ts create mode 100644 src/lib/i18n/phone.ts create mode 100644 src/lib/i18n/subdivisions.ts create mode 100644 src/lib/i18n/timezones.ts create mode 100644 src/lib/validators/i18n.ts create mode 100644 tests/e2e/smoke/31-i18n-form-fields.spec.ts create mode 100644 tests/integration/public-residential-inquiry.test.ts create mode 100644 tests/unit/i18n-countries.test.ts create mode 100644 tests/unit/i18n-phone.test.ts create mode 100644 tests/unit/i18n-subdivisions.test.ts create mode 100644 tests/unit/i18n-timezones.test.ts diff --git a/package.json b/package.json index da385b6..37cfedc 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,9 @@ "drizzle-orm": "^0.38.0", "imapflow": "^1.2.13", "ioredis": "^5.4.0", + "iso-3166-2": "^1.0.0", "jose": "^6.2.1", + "libphonenumber-js": "^1.12.42", "lucide-react": "^0.460.0", "mailparser": "^3.9.4", "minio": "^8.0.0", @@ -89,6 +91,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.5", "@playwright/test": "^1.58.2", + "@types/iso-3166-2": "^1.0.4", "@types/mailparser": "^3.4.6", "@types/node": "^22.0.0", "@types/nodemailer": "^6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b248cf..22da687 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,9 +128,15 @@ importers: ioredis: specifier: ^5.4.0 version: 5.10.0 + iso-3166-2: + specifier: ^1.0.0 + version: 1.0.0 jose: specifier: ^6.2.1 version: 6.2.1 + libphonenumber-js: + specifier: ^1.12.42 + version: 1.12.42 lucide-react: specifier: ^0.460.0 version: 0.460.0(react@19.2.4) @@ -207,6 +213,9 @@ importers: '@playwright/test': specifier: ^1.58.2 version: 1.58.2 + '@types/iso-3166-2': + specifier: ^1.0.4 + version: 1.0.4 '@types/mailparser': specifier: ^3.4.6 version: 3.4.6 @@ -2264,6 +2273,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/iso-3166-2@1.0.4': + resolution: {integrity: sha512-tXaeT4FDobC8rAy6LoFvbGA4vhOQQNIdSRC5DAoYfT3D9ohnKHkDFxHzSln6WqTKVeKLrnMiMQubM8m3fqNp/w==} + '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} @@ -3887,6 +3899,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + iso-3166-2@1.0.0: + resolution: {integrity: sha512-xLAazfKZzwlsg/Zz/GQGQk3jJez5/2ORrjD3TjSuqz/arMht/xTK49c0GOE3afO/gEd9tHtBVVlfBla01unUng==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -3975,6 +3990,9 @@ packages: libmime@5.3.7: resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==} + libphonenumber-js@1.12.42: + resolution: {integrity: sha512-oKQFPTibqQwZZkChCDVMFVJXMZdyJNqDWZWYNn8BgyAaK/6yFJEowxCY0RVFirRyWP63hMRuKlkSEd9qlvbWXg==} + libqp@2.1.1: resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} @@ -7311,6 +7329,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/iso-3166-2@1.0.4': {} + '@types/js-cookie@3.0.6': {} '@types/json-schema@7.0.15': {} @@ -9172,6 +9192,8 @@ snapshots: isexe@3.1.5: {} + iso-3166-2@1.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -9259,6 +9281,8 @@ snapshots: libbase64: 1.3.0 libqp: 2.1.1 + libphonenumber-js@1.12.42: {} + libqp@2.1.1: {} lightningcss-android-arm64@1.32.0: diff --git a/src/app/api/public/residential-inquiries/route.ts b/src/app/api/public/residential-inquiries/route.ts index 9a963da..0c1cf9d 100644 --- a/src/app/api/public/residential-inquiries/route.ts +++ b/src/app/api/public/residential-inquiries/route.ts @@ -16,6 +16,8 @@ import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { publicResidentialInquirySchema } from '@/lib/validators/residential'; import { emitToRoom } from '@/lib/socket/server'; +import { parsePhone } from '@/lib/i18n/phone'; +import type { CountryCode } from '@/lib/i18n/countries'; // ─── Rate limiter (5 per hour per IP) ──────────────────────────────────────── @@ -61,6 +63,16 @@ export async function POST(req: NextRequest) { throw new ValidationError('Unknown port'); } + // If the website didn't pre-normalize, parse server-side. International + // strings parse without a hint; national-format submissions need a country. + let phoneE164 = data.phoneE164 ?? null; + let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null; + if (!phoneE164) { + const parsed = parsePhone(data.phone, phoneCountry ?? undefined); + phoneE164 = parsed.e164; + phoneCountry = parsed.country ?? phoneCountry; + } + const result = await withTransaction(async (tx) => { const [client] = await tx .insert(residentialClients) @@ -69,7 +81,13 @@ export async function POST(req: NextRequest) { fullName: `${data.firstName.trim()} ${data.lastName.trim()}`.trim(), email: data.email, phone: data.phone, + phoneE164, + phoneCountry, + nationalityIso: data.nationalityIso ?? null, + timezone: data.timezone ?? null, placeOfResidence: data.placeOfResidence, + placeOfResidenceCountryIso: data.placeOfResidenceCountryIso ?? null, + subdivisionIso: data.subdivisionIso ?? null, preferredContactMethod: data.preferredContactMethod, source: 'website', status: 'prospect', diff --git a/src/app/api/v1/clients/[id]/contacts/[contactId]/route.ts b/src/app/api/v1/clients/[id]/contacts/[contactId]/route.ts index 3140014..cc96b18 100644 --- a/src/app/api/v1/clients/[id]/contacts/[contactId]/route.ts +++ b/src/app/api/v1/clients/[id]/contacts/[contactId]/route.ts @@ -5,10 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; import { updateContact, removeContact } from '@/lib/services/clients.service'; +import { optionalCountryIsoSchema, optionalPhoneE164Schema } from '@/lib/validators/i18n'; const updateContactSchema = z.object({ channel: z.enum(['email', 'phone', 'whatsapp', 'other']).optional(), value: z.string().min(1).optional(), + valueE164: optionalPhoneE164Schema.optional(), + valueCountry: optionalCountryIsoSchema.optional(), label: z.string().optional(), isPrimary: z.boolean().optional(), notes: z.string().optional(), @@ -18,18 +21,12 @@ export const PATCH = withAuth( withPermission('clients', 'edit', async (req, ctx, params) => { try { const body = await parseBody(req, updateContactSchema); - const contact = await updateContact( - params.contactId!, - params.id!, - ctx.portId, - body, - { - userId: ctx.userId, - portId: ctx.portId, - ipAddress: ctx.ipAddress, - userAgent: ctx.userAgent, - }, - ); + const contact = await updateContact(params.contactId!, params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); return NextResponse.json({ data: contact }); } catch (error) { return errorResponse(error); diff --git a/src/app/api/v1/clients/[id]/contacts/route.ts b/src/app/api/v1/clients/[id]/contacts/route.ts index abd37a3..753c369 100644 --- a/src/app/api/v1/clients/[id]/contacts/route.ts +++ b/src/app/api/v1/clients/[id]/contacts/route.ts @@ -5,10 +5,13 @@ import { withAuth, withPermission } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; import { listContacts, addContact } from '@/lib/services/clients.service'; +import { optionalCountryIsoSchema, optionalPhoneE164Schema } from '@/lib/validators/i18n'; const addContactSchema = z.object({ channel: z.enum(['email', 'phone', 'whatsapp', 'other']), value: z.string().min(1), + valueE164: optionalPhoneE164Schema.optional(), + valueCountry: optionalCountryIsoSchema.optional(), label: z.string().optional(), isPrimary: z.boolean().optional().default(false), notes: z.string().optional(), diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 9d4fa3d..5f34851 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -20,8 +20,12 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { TagPicker } from '@/components/shared/tag-picker'; +import { CountryCombobox } from '@/components/shared/country-combobox'; +import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; +import { PhoneInput } from '@/components/shared/phone-input'; import { apiFetch } from '@/lib/api/client'; import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients'; +import type { CountryCode } from '@/lib/i18n/countries'; interface ClientFormProps { open: boolean; @@ -31,6 +35,7 @@ interface ClientFormProps { id: string; fullName: string; nationality?: string | null; + nationalityIso?: string | null; preferredContactMethod?: string | null; preferredLanguage?: string | null; timezone?: string | null; @@ -39,6 +44,8 @@ interface ClientFormProps { contacts?: Array<{ channel: string; value: string; + valueE164?: string | null; + valueCountry?: string | null; label?: string | null; isPrimary?: boolean; notes?: string | null; @@ -77,6 +84,7 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) { reset({ fullName: client.fullName, nationality: client.nationality ?? undefined, + nationalityIso: client.nationalityIso ?? undefined, preferredContactMethod: (client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ?? undefined, @@ -89,6 +97,8 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) { ? client.contacts.map((c) => ({ channel: c.channel as 'email' | 'phone' | 'whatsapp' | 'other', value: c.value, + valueE164: c.valueE164 ?? undefined, + valueCountry: c.valueCountry ?? undefined, label: c.label ?? undefined, isPrimary: c.isPrimary ?? false, notes: c.notes ?? undefined, @@ -152,7 +162,11 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
- + setValue('nationalityIso', iso ?? undefined)} + data-testid="client-nationality" + />
@@ -211,11 +225,40 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
- + {(() => { + const channel = watch(`contacts.${index}.channel`); + if (channel === 'phone' || channel === 'whatsapp') { + const e164 = watch(`contacts.${index}.valueE164`) ?? null; + const country = + (watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ?? + undefined; + return ( + { + setValue(`contacts.${index}.value`, v.e164 ?? ''); + setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined); + setValue(`contacts.${index}.valueCountry`, v.country); + }} + data-testid={`contact-${index}-phone`} + /> + ); + } + return ( + + ); + })()}
@@ -304,7 +347,12 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
- + setValue('timezone', tz ?? undefined)} + countryHint={(watch('nationalityIso') as CountryCode | undefined) ?? undefined} + data-testid="client-timezone" + />
diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index cccf4f0..5de89fb 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -4,8 +4,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { DetailTab } from '@/components/shared/detail-layout'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { InlineCountryField } from '@/components/shared/inline-country-field'; +import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; +import type { CountryCode } from '@/lib/i18n/countries'; import { ClientYachtsTab } from '@/components/clients/client-yachts-tab'; import { ClientCompaniesTab } from '@/components/clients/client-companies-tab'; import { ClientReservationsTab } from '@/components/clients/client-reservations-tab'; @@ -15,6 +18,7 @@ import { apiFetch } from '@/lib/api/client'; type ClientPatchField = | 'fullName' | 'nationality' + | 'nationalityIso' | 'preferredContactMethod' | 'preferredLanguage' | 'timezone' @@ -64,6 +68,7 @@ interface ClientTabsOptions { client: { fullName: string; nationality?: string | null; + nationalityIso?: string | null; preferredContactMethod?: string | null; preferredLanguage?: string | null; timezone?: string | null; @@ -73,6 +78,8 @@ interface ClientTabsOptions { id: string; channel: string; value: string; + valueE164?: string | null; + valueCountry?: string | null; label?: string | null; isPrimary: boolean; }>; @@ -131,7 +138,13 @@ function OverviewTab({ - + { + await mutation.mutateAsync({ nationalityIso: iso }); + }} + data-testid="client-nationality-inline" + /> - + { + await mutation.mutateAsync({ timezone: tz }); + }} + data-testid="client-timezone-inline" + /> >; + patch: Partial< + Pick + >; }) => apiFetch(`/api/v1/clients/${clientId}/contacts/${contactId}`, { method: 'PATCH', @@ -73,7 +79,13 @@ export function ContactsEditor({ clientId, contacts }: { clientId: string; conta }); const addMutation = useMutation({ - mutationFn: async (data: { channel: string; value: string; label?: string }) => + mutationFn: async (data: { + channel: string; + value: string; + valueE164?: string | null; + valueCountry?: string | null; + label?: string; + }) => apiFetch(`/api/v1/clients/${clientId}/contacts`, { method: 'POST', body: { ...data, isPrimary: false }, @@ -136,7 +148,9 @@ function ContactRow({ }: { contact: Contact; onUpdate: ( - patch: Partial>, + patch: Partial< + Pick + >, ) => Promise; onRemove: () => void; }) { @@ -167,16 +181,30 @@ function ContactRow({
- { - if (!v) { - toast.error('Value is required'); - return; - } - await onUpdate({ value: v }); - }} - /> + {contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( + { + if (!e164) { + toast.error('Phone number is required'); + return; + } + await onUpdate({ value: e164, valueE164: e164, valueCountry: country }); + }} + /> + ) : ( + { + if (!v) { + toast.error('Value is required'); + return; + } + await onUpdate({ value: v }); + }} + /> + )}
@@ -252,15 +280,42 @@ function NewContactForm({ onSave, onCancel, }: { - onSave: (data: { channel: string; value: string; label?: string }) => Promise; + onSave: (data: { + channel: string; + value: string; + valueE164?: string | null; + valueCountry?: string | null; + label?: string; + }) => Promise; onCancel: () => void; }) { const [channel, setChannel] = useState('email'); const [value, setValue] = useState(''); + const [phoneValue, setPhoneValue] = useState(null); const [label, setLabel] = useState(''); const [saving, setSaving] = useState(false); + const isPhoneChannel = channel === 'phone' || channel === 'whatsapp'; + async function submit() { + if (isPhoneChannel) { + if (!phoneValue?.e164) return; + setSaving(true); + try { + await onSave({ + channel, + value: phoneValue.e164, + valueE164: phoneValue.e164, + valueCountry: phoneValue.country, + label: label.trim() || undefined, + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to add contact'); + } finally { + setSaving(false); + } + return; + } if (!value.trim()) return; setSaving(true); try { @@ -272,9 +327,19 @@ function NewContactForm({ } } + const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim()); + return (
- { + setChannel(next); + // Reset cross-mode state so a stale email doesn't ride along on a phone submit. + if (next === 'phone' || next === 'whatsapp') setValue(''); + else setPhoneValue(null); + }} + > @@ -287,21 +352,31 @@ function NewContactForm({ - setValue(e.target.value)} - placeholder={channel === 'email' ? 'name@example.com' : '+1 555 0100'} - className="h-7 text-sm flex-1 min-w-0" - autoFocus - disabled={saving} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - void submit(); - } - if (e.key === 'Escape') onCancel(); - }} - /> + {isPhoneChannel ? ( +
+ setPhoneValue(v)} + data-testid="new-contact-phone" + /> +
+ ) : ( + setValue(e.target.value)} + placeholder={channel === 'email' ? 'name@example.com' : 'value'} + className="h-7 text-sm flex-1 min-w-0" + autoFocus + disabled={saving} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void submit(); + } + if (e.key === 'Escape') onCancel(); + }} + /> + )} -
- + { + setValue('incorporationCountryIso', iso ?? undefined); + // Wipe subdivision when country flips — codes are country-scoped. + setValue('incorporationSubdivisionIso', undefined); + }} + data-testid="company-incorp-country" + /> +
+
+ + setValue('incorporationSubdivisionIso', code ?? undefined)} + country={(watch('incorporationCountryIso') as CountryCode | undefined) ?? null} + data-testid="company-incorp-subdivision" + />
diff --git a/src/components/companies/company-list.tsx b/src/components/companies/company-list.tsx index c386251..5d5d95a 100644 --- a/src/components/companies/company-list.tsx +++ b/src/components/companies/company-list.tsx @@ -146,6 +146,8 @@ export function CompanyList() { taxId: editCompany.taxId, registrationNumber: editCompany.registrationNumber, incorporationCountry: editCompany.incorporationCountry, + incorporationCountryIso: editCompany.incorporationCountryIso, + incorporationSubdivisionIso: editCompany.incorporationSubdivisionIso, incorporationDate: editCompany.incorporationDate, status: editCompany.status, billingEmail: editCompany.billingEmail, diff --git a/src/components/companies/company-tabs.tsx b/src/components/companies/company-tabs.tsx index ea27edd..ed88099 100644 --- a/src/components/companies/company-tabs.tsx +++ b/src/components/companies/company-tabs.tsx @@ -5,11 +5,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { DetailTab } from '@/components/shared/detail-layout'; import { EmptyState } from '@/components/shared/empty-state'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { InlineCountryField } from '@/components/shared/inline-country-field'; +import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; import { CompanyMembersTab } from '@/components/companies/company-members-tab'; import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab'; import { apiFetch } from '@/lib/api/client'; +import type { CountryCode } from '@/lib/i18n/countries'; type CompanyPatchField = | 'name' @@ -17,6 +20,8 @@ type CompanyPatchField = | 'taxId' | 'registrationNumber' | 'incorporationCountry' + | 'incorporationCountryIso' + | 'incorporationSubdivisionIso' | 'incorporationDate' | 'status' | 'billingEmail' @@ -34,6 +39,8 @@ interface CompanyTabsCompany { taxId: string | null; registrationNumber: string | null; incorporationCountry: string | null; + incorporationCountryIso: string | null; + incorporationSubdivisionIso: string | null; incorporationDate: string | null; status: string; billingEmail: string | null; @@ -114,9 +121,26 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa /> - { + // Wipe subdivision when country flips — codes are country-scoped. + await mutation.mutateAsync({ + incorporationCountryIso: iso, + incorporationSubdivisionIso: null, + }); + }} + data-testid="company-incorp-country-inline" + /> + + + { + void mutation.mutateAsync({ incorporationSubdivisionIso: code }); + }} + country={(company.incorporationCountryIso as CountryCode | null) ?? null} + data-testid="company-incorp-subdivision-inline" /> diff --git a/src/components/residential/residential-client-detail.tsx b/src/components/residential/residential-client-detail.tsx index 43fa5f5..2f77745 100644 --- a/src/components/residential/residential-client-detail.tsx +++ b/src/components/residential/residential-client-detail.tsx @@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { InlineCountryField } from '@/components/shared/inline-country-field'; +import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; +import { InlinePhoneField } from '@/components/shared/inline-phone-field'; +import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import type { CountryCode } from '@/lib/i18n/countries'; interface ResidentialInterestSummary { id: string; @@ -29,7 +34,13 @@ interface ResidentialClientDetail { fullName: string; email: string | null; phone: string | null; + phoneE164: string | null; + phoneCountry: string | null; + nationalityIso: string | null; + timezone: string | null; placeOfResidence: string | null; + placeOfResidenceCountryIso: string | null; + subdivisionIso: string | null; preferredContactMethod: string | null; status: string; source: string | null; @@ -130,7 +141,17 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) { - + { + await update.mutateAsync({ + phone: e164, + phoneE164: e164, + phoneCountry: country, + }); + }} + /> + + { + await update.mutateAsync({ nationalityIso: iso }); + }} + /> + + + { + await update.mutateAsync({ timezone: tz }); + }} + /> + + + { + // When country flips, clear the subdivision — codes are country-scoped. + await update.mutateAsync({ + placeOfResidenceCountryIso: iso, + subdivisionIso: null, + }); + }} + /> + + + { + void update.mutateAsync({ subdivisionIso: code }); + }} + country={(client.placeOfResidenceCountryIso as CountryCode | null) ?? null} + /> +
diff --git a/src/components/residential/residential-clients-list.tsx b/src/components/residential/residential-clients-list.tsx index 62ad644..9f0cb16 100644 --- a/src/components/residential/residential-clients-list.tsx +++ b/src/components/residential/residential-clients-list.tsx @@ -12,8 +12,13 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { PageHeader } from '@/components/shared/page-header'; +import { CountryCombobox } from '@/components/shared/country-combobox'; +import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; +import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox'; +import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; +import type { CountryCode } from '@/lib/i18n/countries'; interface ResidentialClientRow { id: string; @@ -147,10 +152,26 @@ function NewResidentialClientSheet({ const qc = useQueryClient(); const [fullName, setFullName] = useState(''); const [email, setEmail] = useState(''); - const [phone, setPhone] = useState(''); + const [phone, setPhone] = useState(null); + const [nationalityIso, setNationalityIso] = useState(null); + const [timezone, setTimezone] = useState(null); const [placeOfResidence, setPlaceOfResidence] = useState(''); + const [residenceCountry, setResidenceCountry] = useState(null); + const [residenceSubdivision, setResidenceSubdivision] = useState(null); const [notes, setNotes] = useState(''); + function reset() { + setFullName(''); + setEmail(''); + setPhone(null); + setNationalityIso(null); + setTimezone(null); + setPlaceOfResidence(''); + setResidenceCountry(null); + setResidenceSubdivision(null); + setNotes(''); + } + const create = useMutation({ mutationFn: () => apiFetch('/api/v1/residential/clients', { @@ -158,8 +179,14 @@ function NewResidentialClientSheet({ body: { fullName, email: email || undefined, - phone: phone || undefined, + phone: phone?.e164 ?? undefined, + phoneE164: phone?.e164 ?? undefined, + phoneCountry: phone?.country ?? undefined, + nationalityIso: nationalityIso ?? undefined, + timezone: timezone ?? undefined, placeOfResidence: placeOfResidence || undefined, + placeOfResidenceCountryIso: residenceCountry ?? undefined, + subdivisionIso: residenceSubdivision ?? undefined, notes: notes || undefined, source: 'manual', }, @@ -167,11 +194,7 @@ function NewResidentialClientSheet({ onSuccess: () => { qc.invalidateQueries({ queryKey: ['residential-clients'] }); onOpenChange(false); - setFullName(''); - setEmail(''); - setPhone(''); - setPlaceOfResidence(''); - setNotes(''); + reset(); toast.success('Residential client added'); }, onError: (err) => { @@ -212,7 +235,28 @@ function NewResidentialClientSheet({
- setPhone(e.target.value)} /> + +
+
+
+ + +
+
+ + +
@@ -220,8 +264,34 @@ function NewResidentialClientSheet({ id="rc-residence" value={placeOfResidence} onChange={(e) => setPlaceOfResidence(e.target.value)} + placeholder="City or area" />
+
+
+ + { + setResidenceCountry(iso); + // Wipe subdivision when country flips — codes are scoped per country. + setResidenceSubdivision(null); + }} + data-testid="rc-residence-country" + /> +
+
+ + +
+
setNotes(e.target.value)} /> diff --git a/src/components/shared/country-combobox.tsx b/src/components/shared/country-combobox.tsx new file mode 100644 index 0000000..4cc2925 --- /dev/null +++ b/src/components/shared/country-combobox.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { ALL_COUNTRY_CODES, getCountryName, type CountryCode } from '@/lib/i18n/countries'; + +interface CountryComboboxProps { + value: string | null | undefined; + onChange: (iso: CountryCode | null) => void; + /** Display locale; defaults to navigator.language so country names follow the user. */ + locale?: string; + /** When true, renders just the flag/code (compact 24×24 trigger). */ + compact?: boolean; + placeholder?: string; + disabled?: boolean; + className?: string; + /** Allow clearing the selection. */ + clearable?: boolean; + id?: string; + 'data-testid'?: string; +} + +/** + * Returns the regional-indicator emoji flag for an ISO alpha-2 code. + * E.g. 'GB' → 🇬🇧. Avoids shipping a flag-image asset and respects the + * platform's emoji rendering (iOS/macOS render real flags; Windows + * shows the country code on a flag rectangle). + */ +function flagEmoji(code: string): string { + if (code.length !== 2) return ''; + const A = 0x1f1e6; + const a = 'A'.charCodeAt(0); + const cp1 = A + code.charCodeAt(0) - a; + const cp2 = A + code.charCodeAt(1) - a; + return String.fromCodePoint(cp1, cp2); +} + +export function CountryCombobox({ + value, + onChange, + locale, + compact = false, + placeholder = 'Select country…', + disabled, + className, + clearable = true, + id, + 'data-testid': testId, +}: CountryComboboxProps) { + const [open, setOpen] = useState(false); + const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'); + + // Pre-build the options list once per locale change so the cmdk filter + // can search by both code + localized name without re-allocating. + const options = useMemo(() => { + return ALL_COUNTRY_CODES.map((code) => ({ + code, + name: getCountryName(code, effectiveLocale), + flag: flagEmoji(code), + })).sort((a, b) => a.name.localeCompare(b.name, effectiveLocale)); + }, [effectiveLocale]); + + const selected = value ? options.find((o) => o.code === value) : undefined; + + return ( + + + + + + + + + No country found. + {clearable && value ? ( + + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear selection + + + ) : null} + + {options.map((opt) => ( + { + onChange(opt.code); + setOpen(false); + }} + > + + {opt.flag} + {opt.name} + {opt.code} + + ))} + + + + + + ); +} diff --git a/src/components/shared/inline-country-field.tsx b/src/components/shared/inline-country-field.tsx new file mode 100644 index 0000000..a4656dd --- /dev/null +++ b/src/components/shared/inline-country-field.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Pencil } from 'lucide-react'; +import { toast } from 'sonner'; + +import { CountryCombobox } from '@/components/shared/country-combobox'; +import { getCountryName, type CountryCode } from '@/lib/i18n/countries'; +import { cn } from '@/lib/utils'; + +interface InlineCountryFieldProps { + value: string | null | undefined; + onSave: (next: CountryCode | null) => Promise; + emptyText?: string; + disabled?: boolean; + className?: string; + 'data-testid'?: string; +} + +/** + * Click-to-edit country picker. Renders the localized country name with a + * regional-indicator flag glyph; opens a CountryCombobox on click. + */ +export function InlineCountryField({ + value, + onSave, + emptyText = '—', + disabled, + className, + 'data-testid': testId, +}: InlineCountryFieldProps) { + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + async function commit(next: CountryCode | null) { + if (next === (value ?? null)) { + setEditing(false); + return; + } + setSaving(true); + try { + await onSave(next); + setEditing(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + } + + if (editing) { + return ( +
+ void commit(iso)} data-testid={testId} /> + {saving && } +
+ ); + } + + const display = value + ? `${flagEmoji(value)} ${getCountryName(value, typeof navigator !== 'undefined' ? navigator.language : 'en')}` + : null; + + return ( + + ); +} + +function flagEmoji(code: string): string { + if (code.length !== 2) return ''; + const A = 0x1f1e6; + const a = 'A'.charCodeAt(0); + return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a); +} diff --git a/src/components/shared/inline-phone-field.tsx b/src/components/shared/inline-phone-field.tsx new file mode 100644 index 0000000..bb808ce --- /dev/null +++ b/src/components/shared/inline-phone-field.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Pencil } from 'lucide-react'; +import { toast } from 'sonner'; + +import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; +import { parsePhone } from '@/lib/i18n/phone'; +import type { CountryCode } from '@/lib/i18n/countries'; +import { cn } from '@/lib/utils'; + +interface InlinePhoneFieldProps { + /** E.164 form ('+442079460958'), null when unset. */ + e164: string | null | undefined; + /** ISO-3166-1 alpha-2 the number was parsed against. */ + country: string | null | undefined; + /** Falls back to this country if `country` isn't set. */ + defaultCountry?: CountryCode; + onSave: (next: { e164: string | null; country: CountryCode }) => Promise; + emptyText?: string; + disabled?: boolean; + className?: string; + 'data-testid'?: string; +} + +export function InlinePhoneField({ + e164, + country, + defaultCountry, + onSave, + emptyText = '—', + disabled, + className, + 'data-testid': testId, +}: InlinePhoneFieldProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(() => { + if (!e164 && !country) return null; + return { + e164: e164 ?? null, + country: (country as CountryCode | null) ?? defaultCountry ?? 'US', + }; + }); + const [saving, setSaving] = useState(false); + + async function commit() { + const next = draft ?? { e164: null, country: defaultCountry ?? 'US' }; + if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) { + setEditing(false); + return; + } + setSaving(true); + try { + await onSave(next); + setEditing(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + } + + if (editing) { + return ( +
+ setDraft(v)} + defaultCountry={defaultCountry} + data-testid={testId} + /> + + +
+ ); + } + + // Display: prefer the parsed national format (more readable than raw E.164). + let display: string | null = null; + if (e164) { + const parsed = parsePhone(e164, (country as CountryCode | undefined) ?? defaultCountry); + display = parsed.national ?? e164; + } + + return ( + + ); +} diff --git a/src/components/shared/inline-timezone-field.tsx b/src/components/shared/inline-timezone-field.tsx new file mode 100644 index 0000000..4d1589d --- /dev/null +++ b/src/components/shared/inline-timezone-field.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Pencil } from 'lucide-react'; +import { toast } from 'sonner'; + +import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; +import { formatTimezoneLabel } from '@/lib/i18n/timezones'; +import type { CountryCode } from '@/lib/i18n/countries'; +import { cn } from '@/lib/utils'; + +interface InlineTimezoneFieldProps { + value: string | null | undefined; + onSave: (next: string | null) => Promise; + /** Optional country to surface "Suggested" zones in the picker. */ + countryHint?: CountryCode | null; + emptyText?: string; + disabled?: boolean; + className?: string; + 'data-testid'?: string; +} + +export function InlineTimezoneField({ + value, + onSave, + countryHint, + emptyText = '—', + disabled, + className, + 'data-testid': testId, +}: InlineTimezoneFieldProps) { + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + async function commit(next: string | null) { + if (next === (value ?? null)) { + setEditing(false); + return; + } + setSaving(true); + try { + await onSave(next); + setEditing(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + } + + if (editing) { + return ( +
+ void commit(tz)} + countryHint={countryHint ?? undefined} + data-testid={testId} + /> + {saving && } +
+ ); + } + + const display = value ? formatTimezoneLabel(value) : null; + + return ( + + ); +} diff --git a/src/components/shared/phone-input.tsx b/src/components/shared/phone-input.tsx new file mode 100644 index 0000000..55bd271 --- /dev/null +++ b/src/components/shared/phone-input.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { Input } from '@/components/ui/input'; +import { CountryCombobox } from '@/components/shared/country-combobox'; +import { cn } from '@/lib/utils'; +import { callingCodeFor, formatAsYouType, parsePhone } from '@/lib/i18n/phone'; +import { detectDefaultCountry, type CountryCode } from '@/lib/i18n/countries'; + +export interface PhoneInputValue { + /** E.164 form ('+442079460958'). Null when empty or unparseable. */ + e164: string | null; + /** Country selected in the dropdown — drives the AsYouType formatter. */ + country: CountryCode; +} + +interface PhoneInputProps { + value: PhoneInputValue | null | undefined; + onChange: (next: PhoneInputValue) => void; + /** Pre-selects the dropdown when `value.country` isn't supplied. */ + defaultCountry?: CountryCode; + placeholder?: string; + disabled?: boolean; + required?: boolean; + invalid?: boolean; + id?: string; + 'data-testid'?: string; +} + +/** + * Phone input with a country flag dropdown + format-as-you-type. + * + * Wire shape: emits `{ e164, country }` on every change. E.164 is null + * while the input is too short to parse — that's a form-validation + * concern, not an input concern. Pasting an international number + * (`+1 415…`) auto-switches the country dropdown to match. + * + * Implementation notes: + * - The visible string is always the AsYouType national-format, + * so users see e.g. "020 7946 0958" while typing GB digits. + * - The `country` prop drives the AsYouType context; flipping + * countries reformats the same digits against the new country + * (matches what users expect when they fix a wrong country pick). + */ +export function PhoneInput({ + value, + onChange, + defaultCountry, + placeholder = 'Phone number', + disabled, + required, + invalid, + id, + 'data-testid': testId, +}: PhoneInputProps) { + const [country, setCountry] = useState( + () => value?.country ?? defaultCountry ?? detectDefaultCountry('US'), + ); + const [display, setDisplay] = useState(() => { + if (!value?.e164) return ''; + const parsed = parsePhone(value.e164, value.country); + return parsed.national ?? ''; + }); + // Track whether the user has typed since mount — keeps a controlled-from-props + // value sync on first render only. + const initialized = useRef(false); + + useEffect(() => { + if (initialized.current) return; + initialized.current = true; + }, []); + + function emit(rawDigits: string, currentCountry: CountryCode) { + const parsed = parsePhone(rawDigits, currentCountry); + onChange({ e164: parsed.e164, country: currentCountry }); + } + + function handleInput(raw: string) { + // Paste-detect: if user pasted an international format, parse it + // and flip the country dropdown to match — better UX than asking + // them to also click the dropdown. + if (raw.startsWith('+')) { + const parsed = parsePhone(raw); + if (parsed.country) { + setCountry(parsed.country); + const reformatted = formatAsYouType(raw, parsed.country); + setDisplay(reformatted); + emit(raw, parsed.country); + return; + } + } + const formatted = formatAsYouType(raw, country); + setDisplay(formatted); + emit(formatted, country); + } + + function handleCountryChange(next: CountryCode | null) { + if (!next) return; + setCountry(next); + // Re-run the formatter against the new country so the visible + // string stays consistent with the dropdown. + const reformatted = formatAsYouType(display, next); + setDisplay(reformatted); + emit(reformatted, next); + } + + return ( +
+ +
+ + {callingCodeFor(country)} + + handleInput(e.target.value)} + // Strip the inner Input's own border so it sits flush with the country prefix. + className="flex-1 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + data-testid={testId} + /> +
+
+ ); +} diff --git a/src/components/shared/subdivision-combobox.tsx b/src/components/shared/subdivision-combobox.tsx new file mode 100644 index 0000000..fe1544b --- /dev/null +++ b/src/components/shared/subdivision-combobox.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { subdivisionsForCountry } from '@/lib/i18n/subdivisions'; +import type { CountryCode } from '@/lib/i18n/countries'; + +interface SubdivisionComboboxProps { + value: string | null | undefined; + onChange: (code: string | null) => void; + /** + * Country whose subdivisions populate the dropdown. When the country + * has no recognized subdivisions, the trigger renders disabled with + * an empty-state hint so the form still lays out. + */ + country: CountryCode | null | undefined; + placeholder?: string; + disabled?: boolean; + className?: string; + clearable?: boolean; + id?: string; + 'data-testid'?: string; +} + +export function SubdivisionCombobox({ + value, + onChange, + country, + placeholder = 'Select region…', + disabled, + className, + clearable = true, + id, + 'data-testid': testId, +}: SubdivisionComboboxProps) { + const [open, setOpen] = useState(false); + + const options = useMemo(() => { + if (!country) return []; + return subdivisionsForCountry(country); + }, [country]); + + const selected = value ? options.find((o) => o.code === value) : undefined; + const noCountry = !country; + const noSubdivisions = !noCountry && options.length === 0; + const isDisabled = disabled || noCountry || noSubdivisions; + + let triggerLabel: string; + if (selected) triggerLabel = selected.name; + else if (noCountry) triggerLabel = 'Pick a country first'; + else if (noSubdivisions) triggerLabel = 'No regions available'; + else triggerLabel = placeholder; + + return ( + + + + + + + + + No region found. + {clearable && value ? ( + + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear selection + + + ) : null} + + {options.map((opt) => ( + { + onChange(opt.code); + setOpen(false); + }} + > + + {opt.name} + {opt.code} + + ))} + + + + + + ); +} diff --git a/src/components/shared/timezone-combobox.tsx b/src/components/shared/timezone-combobox.tsx new file mode 100644 index 0000000..fc495a7 --- /dev/null +++ b/src/components/shared/timezone-combobox.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { formatTimezoneLabel, listAllTimezones, timezonesForCountry } from '@/lib/i18n/timezones'; +import type { CountryCode } from '@/lib/i18n/countries'; + +interface TimezoneComboboxProps { + value: string | null | undefined; + onChange: (iana: string | null) => void; + /** When set, the dropdown surfaces matching zones first under a "Suggested" group. */ + countryHint?: CountryCode; + placeholder?: string; + disabled?: boolean; + className?: string; + clearable?: boolean; + id?: string; + 'data-testid'?: string; +} + +export function TimezoneCombobox({ + value, + onChange, + countryHint, + placeholder = 'Select timezone…', + disabled, + className, + clearable = true, + id, + 'data-testid': testId, +}: TimezoneComboboxProps) { + const [open, setOpen] = useState(false); + + const allOptions = useMemo(() => { + return listAllTimezones().map((tz) => ({ + tz, + label: formatTimezoneLabel(tz), + })); + }, []); + + const suggested = useMemo(() => { + if (!countryHint) return []; + const set = new Set(timezonesForCountry(countryHint)); + return allOptions.filter((o) => set.has(o.tz)); + }, [allOptions, countryHint]); + + const rest = useMemo(() => { + if (!suggested.length) return allOptions; + const suggestedSet = new Set(suggested.map((s) => s.tz)); + return allOptions.filter((o) => !suggestedSet.has(o.tz)); + }, [allOptions, suggested]); + + const selectedLabel = value ? formatTimezoneLabel(value) : placeholder; + + return ( + + + + + + + + + No timezone found. + {clearable && value ? ( + + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear selection + + + ) : null} + {suggested.length > 0 ? ( + <> + + {suggested.map((opt) => ( + { + onChange(opt.tz); + setOpen(false); + }} + > + + {opt.label} + + ))} + + + + ) : null} + 0 ? 'All zones' : undefined}> + {rest.map((opt) => ( + { + onChange(opt.tz); + setOpen(false); + }} + > + + {opt.label} + + ))} + + + + + + ); +} diff --git a/src/lib/db/migrations/0015_i18n_columns.sql b/src/lib/db/migrations/0015_i18n_columns.sql new file mode 100644 index 0000000..d469f19 --- /dev/null +++ b/src/lib/db/migrations/0015_i18n_columns.sql @@ -0,0 +1,16 @@ +ALTER TABLE "client_addresses" ADD COLUMN "subdivision_iso" text;--> statement-breakpoint +ALTER TABLE "client_addresses" ADD COLUMN "country_iso" text;--> statement-breakpoint +ALTER TABLE "client_contacts" ADD COLUMN "value_e164" text;--> statement-breakpoint +ALTER TABLE "client_contacts" ADD COLUMN "value_country" text;--> statement-breakpoint +ALTER TABLE "clients" ADD COLUMN "nationality_iso" text;--> statement-breakpoint +ALTER TABLE "companies" ADD COLUMN "incorporation_country_iso" text;--> statement-breakpoint +ALTER TABLE "companies" ADD COLUMN "incorporation_subdivision_iso" text;--> statement-breakpoint +ALTER TABLE "company_addresses" ADD COLUMN "subdivision_iso" text;--> statement-breakpoint +ALTER TABLE "company_addresses" ADD COLUMN "country_iso" text;--> statement-breakpoint +ALTER TABLE "residential_clients" ADD COLUMN "phone_e164" text;--> statement-breakpoint +ALTER TABLE "residential_clients" ADD COLUMN "phone_country" text;--> statement-breakpoint +ALTER TABLE "residential_clients" ADD COLUMN "nationality_iso" text;--> statement-breakpoint +ALTER TABLE "residential_clients" ADD COLUMN "timezone" text;--> statement-breakpoint +ALTER TABLE "residential_clients" ADD COLUMN "place_of_residence_country_iso" text;--> statement-breakpoint +ALTER TABLE "residential_clients" ADD COLUMN "subdivision_iso" text;--> statement-breakpoint +CREATE INDEX "idx_clients_nationality_iso" ON "clients" USING btree ("nationality_iso"); \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0015_snapshot.json b/src/lib/db/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..85aeeb0 --- /dev/null +++ b/src/lib/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,9885 @@ +{ + "id": "150aaf79-1b4c-413b-ac04-1660454ee759", + "prevId": "478bb330-ef5d-419a-ae6c-a54c3d001085", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.berth_maintenance_log": { + "name": "berth_maintenance_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USD'" + }, + "responsible_party": { + "name": "responsible_party", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "performed_date": { + "name": "performed_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "photo_file_ids": { + "name": "photo_file_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_bml_berth": { + "name": "idx_bml_berth", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bml_port": { + "name": "idx_bml_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "berth_maintenance_log_berth_id_berths_id_fk": { + "name": "berth_maintenance_log_berth_id_berths_id_fk", + "tableFrom": "berth_maintenance_log", + "tableTo": "berths", + "columnsFrom": ["berth_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "berth_maintenance_log_port_id_ports_id_fk": { + "name": "berth_maintenance_log_port_id_ports_id_fk", + "tableFrom": "berth_maintenance_log", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.berth_map_data": { + "name": "berth_map_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "svg_path": { + "name": "svg_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x": { + "name": "x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "y": { + "name": "y", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "transform": { + "name": "transform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "extra_data": { + "name": "extra_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "berth_map_data_berth_id_idx": { + "name": "berth_map_data_berth_id_idx", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "berth_map_data_berth_id_berths_id_fk": { + "name": "berth_map_data_berth_id_berths_id_fk", + "tableFrom": "berth_map_data", + "tableTo": "berths", + "columnsFrom": ["berth_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "berth_map_data_berth_id_unique": { + "name": "berth_map_data_berth_id_unique", + "nullsNotDistinct": false, + "columns": ["berth_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.berth_recommendations": { + "name": "berth_recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_score": { + "name": "match_score", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "match_reasons": { + "name": "match_reasons", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ai'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "berth_rec_interest_berth_idx": { + "name": "berth_rec_interest_berth_idx", + "columns": [ + { + "expression": "interest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_br_interest": { + "name": "idx_br_interest", + "columns": [ + { + "expression": "interest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "berth_recommendations_berth_id_berths_id_fk": { + "name": "berth_recommendations_berth_id_berths_id_fk", + "tableFrom": "berth_recommendations", + "tableTo": "berths", + "columnsFrom": ["berth_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.berth_tags": { + "name": "berth_tags", + "schema": "", + "columns": { + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "berth_tags_berth_id_berths_id_fk": { + "name": "berth_tags_berth_id_berths_id_fk", + "tableFrom": "berth_tags", + "tableTo": "berths", + "columnsFrom": ["berth_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "berth_tags_berth_id_tag_id_pk": { + "name": "berth_tags_berth_id_tag_id_pk", + "columns": ["berth_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.berth_waiting_list": { + "name": "berth_waiting_list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "notify_pref": { + "name": "notify_pref", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'email'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "berth_waiting_list_berth_client_idx": { + "name": "berth_waiting_list_berth_client_idx", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bwl_berth": { + "name": "idx_bwl_berth", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "berth_waiting_list_berth_id_berths_id_fk": { + "name": "berth_waiting_list_berth_id_berths_id_fk", + "tableFrom": "berth_waiting_list", + "tableTo": "berths", + "columnsFrom": ["berth_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "berth_waiting_list_client_id_clients_id_fk": { + "name": "berth_waiting_list_client_id_clients_id_fk", + "tableFrom": "berth_waiting_list", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.berths": { + "name": "berths", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mooring_number": { + "name": "mooring_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "length_ft": { + "name": "length_ft", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width_ft": { + "name": "width_ft", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "draft_ft": { + "name": "draft_ft", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "length_m": { + "name": "length_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width_m": { + "name": "width_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "draft_m": { + "name": "draft_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width_is_minimum": { + "name": "width_is_minimum", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "nominal_boat_size": { + "name": "nominal_boat_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nominal_boat_size_m": { + "name": "nominal_boat_size_m", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "water_depth": { + "name": "water_depth", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "water_depth_m": { + "name": "water_depth_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "water_depth_is_minimum": { + "name": "water_depth_is_minimum", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "side_pontoon": { + "name": "side_pontoon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "power_capacity": { + "name": "power_capacity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voltage": { + "name": "voltage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mooring_type": { + "name": "mooring_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleat_type": { + "name": "cleat_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleat_capacity": { + "name": "cleat_capacity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bollard_type": { + "name": "bollard_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bollard_capacity": { + "name": "bollard_capacity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "price_currency": { + "name": "price_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "bow_facing": { + "name": "bow_facing", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "berth_approved": { + "name": "berth_approved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tenure_type": { + "name": "tenure_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'permanent'" + }, + "tenure_years": { + "name": "tenure_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tenure_start_date": { + "name": "tenure_start_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "tenure_end_date": { + "name": "tenure_end_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status_last_changed_by": { + "name": "status_last_changed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_last_changed_reason": { + "name": "status_last_changed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_last_modified": { + "name": "status_last_modified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_berths_port": { + "name": "idx_berths_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_berths_status": { + "name": "idx_berths_status", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_berths_area": { + "name": "idx_berths_area", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "area", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_berths_mooring": { + "name": "idx_berths_mooring", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mooring_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "berths_port_id_ports_id_fk": { + "name": "berths_port_id_ports_id_fk", + "tableFrom": "berths", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_addresses": { + "name": "client_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Primary'" + }, + "street_address": { + "name": "street_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_province": { + "name": "state_province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subdivision_iso": { + "name": "subdivision_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_iso": { + "name": "country_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ca_client": { + "name": "idx_ca_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ca_port": { + "name": "idx_ca_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ca_primary": { + "name": "idx_ca_primary", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"client_addresses\".\"is_primary\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_addresses_client_id_clients_id_fk": { + "name": "client_addresses_client_id_clients_id_fk", + "tableFrom": "client_addresses", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_addresses_port_id_ports_id_fk": { + "name": "client_addresses_port_id_ports_id_fk", + "tableFrom": "client_addresses", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_contacts": { + "name": "client_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value_e164": { + "name": "value_e164", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_country": { + "name": "value_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_cc_client": { + "name": "idx_cc_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_email": { + "name": "idx_cc_email", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"client_contacts\".\"channel\" = 'email'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cc_phone": { + "name": "idx_cc_phone", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"client_contacts\".\"channel\" = 'phone'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_contacts_client_id_clients_id_fk": { + "name": "client_contacts_client_id_clients_id_fk", + "tableFrom": "client_contacts", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_merge_log": { + "name": "client_merge_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surviving_client_id": { + "name": "surviving_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "merged_client_id": { + "name": "merged_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "merged_by": { + "name": "merged_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "merge_details": { + "name": "merge_details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_cml_port": { + "name": "idx_cml_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_merge_log_port_id_ports_id_fk": { + "name": "client_merge_log_port_id_ports_id_fk", + "tableFrom": "client_merge_log", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_merge_log_surviving_client_id_clients_id_fk": { + "name": "client_merge_log_surviving_client_id_clients_id_fk", + "tableFrom": "client_merge_log", + "tableTo": "clients", + "columnsFrom": ["surviving_client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_notes": { + "name": "client_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentions": { + "name": "mentions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_cn_client": { + "name": "idx_cn_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_notes_client_id_clients_id_fk": { + "name": "client_notes_client_id_clients_id_fk", + "tableFrom": "client_notes", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_relationships": { + "name": "client_relationships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_a_id": { + "name": "client_a_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_b_id": { + "name": "client_b_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relationship_type": { + "name": "relationship_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_cr_port": { + "name": "idx_cr_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "client_relationships_port_id_ports_id_fk": { + "name": "client_relationships_port_id_ports_id_fk", + "tableFrom": "client_relationships", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "client_relationships_client_a_id_clients_id_fk": { + "name": "client_relationships_client_a_id_clients_id_fk", + "tableFrom": "client_relationships", + "tableTo": "clients", + "columnsFrom": ["client_a_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "client_relationships_client_b_id_clients_id_fk": { + "name": "client_relationships_client_b_id_clients_id_fk", + "tableFrom": "client_relationships", + "tableTo": "clients", + "columnsFrom": ["client_b_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tags": { + "name": "client_tags", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "client_tags_client_id_clients_id_fk": { + "name": "client_tags_client_id_clients_id_fk", + "tableFrom": "client_tags", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "client_tags_client_id_tag_id_pk": { + "name": "client_tags_client_id_tag_id_pk", + "columns": ["client_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nationality": { + "name": "nationality", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nationality_iso": { + "name": "nationality_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_contact_method": { + "name": "preferred_contact_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_language": { + "name": "preferred_language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_details": { + "name": "source_details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_clients_port": { + "name": "idx_clients_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_clients_name": { + "name": "idx_clients_name", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_clients_archived": { + "name": "idx_clients_archived", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_clients_nationality_iso": { + "name": "idx_clients_nationality_iso", + "columns": [ + { + "expression": "nationality_iso", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_port_id_ports_id_fk": { + "name": "clients_port_id_ports_id_fk", + "tableFrom": "clients", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legal_name": { + "name": "legal_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_number": { + "name": "registration_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incorporation_country": { + "name": "incorporation_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incorporation_country_iso": { + "name": "incorporation_country_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incorporation_subdivision_iso": { + "name": "incorporation_subdivision_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incorporation_date": { + "name": "incorporation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_companies_port": { + "name": "idx_companies_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_companies_name_unique": { + "name": "idx_companies_name_unique", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_companies_taxid": { + "name": "idx_companies_taxid", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tax_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"companies\".\"tax_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "companies_port_id_ports_id_fk": { + "name": "companies_port_id_ports_id_fk", + "tableFrom": "companies", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_addresses": { + "name": "company_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Primary'" + }, + "street_address": { + "name": "street_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_province": { + "name": "state_province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subdivision_iso": { + "name": "subdivision_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_iso": { + "name": "country_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_compa_company": { + "name": "idx_compa_company", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_compa_port": { + "name": "idx_compa_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_compa_primary": { + "name": "idx_compa_primary", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_addresses\".\"is_primary\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_addresses_company_id_companies_id_fk": { + "name": "company_addresses_company_id_companies_id_fk", + "tableFrom": "company_addresses", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_addresses_port_id_ports_id_fk": { + "name": "company_addresses_port_id_ports_id_fk", + "tableFrom": "company_addresses", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_detail": { + "name": "role_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_cm_company": { + "name": "idx_cm_company", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cm_client": { + "name": "idx_cm_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cm_active": { + "name": "idx_cm_active", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"company_memberships\".\"end_date\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_cm_exact": { + "name": "unique_cm_exact", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_memberships_client_id_clients_id_fk": { + "name": "company_memberships_client_id_clients_id_fk", + "tableFrom": "company_memberships", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_notes": { + "name": "company_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentions": { + "name": "mentions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_compn_company": { + "name": "idx_compn_company", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_notes_company_id_companies_id_fk": { + "name": "company_notes_company_id_companies_id_fk", + "tableFrom": "company_notes", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_tags": { + "name": "company_tags", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "company_tags_company_id_companies_id_fk": { + "name": "company_tags_company_id_companies_id_fk", + "tableFrom": "company_tags", + "tableTo": "companies", + "columnsFrom": ["company_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "company_tags_company_id_tag_id_pk": { + "name": "company_tags_company_id_tag_id_pk", + "columns": ["company_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_user_invites": { + "name": "crm_user_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_crm_invites_token_hash": { + "name": "idx_crm_invites_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_crm_invites_email": { + "name": "idx_crm_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_events": { + "name": "document_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer_id": { + "name": "signer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_data": { + "name": "event_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "signature_hash": { + "name": "signature_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_de_doc": { + "name": "idx_de_doc", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_de_dedup": { + "name": "idx_de_dedup", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "signature_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document_events\".\"signature_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_events_document_id_documents_id_fk": { + "name": "document_events_document_id_documents_id_fk", + "tableFrom": "document_events", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_events_signer_id_document_signers_id_fk": { + "name": "document_events_signer_id_document_signers_id_fk", + "tableFrom": "document_events", + "tableTo": "document_signers", + "columnsFrom": ["signer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_signers": { + "name": "document_signers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer_name": { + "name": "signer_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer_email": { + "name": "signer_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signer_role": { + "name": "signer_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signing_order": { + "name": "signing_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "signed_at": { + "name": "signed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "signing_url": { + "name": "signing_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedded_url": { + "name": "embedded_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ds_doc": { + "name": "idx_ds_doc", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_signers_document_id_documents_id_fk": { + "name": "document_signers_document_id_documents_id_fk", + "tableFrom": "document_signers", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_templates": { + "name": "document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "template_type": { + "name": "template_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merge_fields": { + "name": "merge_fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "template_format": { + "name": "template_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'html'" + }, + "source_file_id": { + "name": "source_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "documenso_template_id": { + "name": "documenso_template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "field_mapping": { + "name": "field_mapping", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "overlay_positions": { + "name": "overlay_positions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reminder_cadence_days": { + "name": "reminder_cadence_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_dt_port": { + "name": "idx_dt_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_dt_type": { + "name": "idx_dt_type", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_templates_port_id_ports_id_fk": { + "name": "document_templates_port_id_ports_id_fk", + "tableFrom": "document_templates", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_templates_source_file_id_files_id_fk": { + "name": "document_templates_source_file_id_files_id_fk", + "tableFrom": "document_templates", + "tableTo": "files", + "columnsFrom": ["source_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_watchers": { + "name": "document_watchers", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_doc_watchers_doc": { + "name": "idx_doc_watchers_doc", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_doc_watchers_user": { + "name": "idx_doc_watchers_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_watchers_document_id_documents_id_fk": { + "name": "document_watchers_document_id_documents_id_fk", + "tableFrom": "document_watchers", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_watchers_document_id_user_id_pk": { + "name": "document_watchers_document_id_user_id_pk", + "columns": ["document_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reservation_id": { + "name": "reservation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "documenso_id": { + "name": "documenso_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signed_file_id": { + "name": "signed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_manual_upload": { + "name": "is_manual_upload", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reminders_disabled": { + "name": "reminders_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_cadence_override": { + "name": "reminder_cadence_override", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_docs_port": { + "name": "idx_docs_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_docs_interest": { + "name": "idx_docs_interest", + "columns": [ + { + "expression": "interest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_docs_client": { + "name": "idx_docs_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_yacht": { + "name": "idx_documents_yacht", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_company": { + "name": "idx_documents_company", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_docs_reservation": { + "name": "idx_docs_reservation", + "columns": [ + { + "expression": "reservation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_docs_type": { + "name": "idx_docs_type", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_docs_status_port": { + "name": "idx_docs_status_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_port_id_ports_id_fk": { + "name": "documents_port_id_ports_id_fk", + "tableFrom": "documents", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_client_id_clients_id_fk": { + "name": "documents_client_id_clients_id_fk", + "tableFrom": "documents", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_file_id_files_id_fk": { + "name": "documents_file_id_files_id_fk", + "tableFrom": "documents", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_signed_file_id_files_id_fk": { + "name": "documents_signed_file_id_files_id_fk", + "tableFrom": "documents", + "tableTo": "files", + "columnsFrom": ["signed_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_bucket": { + "name": "storage_bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'crm-files'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_files_port": { + "name": "idx_files_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_client": { + "name": "idx_files_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_yacht": { + "name": "idx_files_yacht", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_company": { + "name": "idx_files_company", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "files_port_id_ports_id_fk": { + "name": "files_port_id_ports_id_fk", + "tableFrom": "files", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_client_id_clients_id_fk": { + "name": "files_client_id_clients_id_fk", + "tableFrom": "files", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_submissions": { + "name": "form_submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "form_template_id": { + "name": "form_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefilled_data": { + "name": "prefilled_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "submitted_data": { + "name": "submitted_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_fs_token": { + "name": "idx_fs_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_submissions_form_template_id_form_templates_id_fk": { + "name": "form_submissions_form_template_id_form_templates_id_fk", + "tableFrom": "form_submissions", + "tableTo": "form_templates", + "columnsFrom": ["form_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "form_submissions_client_id_clients_id_fk": { + "name": "form_submissions_client_id_clients_id_fk", + "tableFrom": "form_submissions", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "form_submissions_token_unique": { + "name": "form_submissions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_templates": { + "name": "form_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "branding": { + "name": "branding", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ft_port": { + "name": "idx_ft_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_templates_port_id_ports_id_fk": { + "name": "form_templates_port_id_ports_id_fk", + "tableFrom": "form_templates", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_accounts": { + "name": "email_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "smtp_host": { + "name": "smtp_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credentials_enc": { + "name": "credentials_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ea_user": { + "name": "idx_ea_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ea_port": { + "name": "idx_ea_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_accounts_port_id_ports_id_fk": { + "name": "email_accounts_port_id_ports_id_fk", + "tableFrom": "email_accounts", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_messages": { + "name": "email_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id_header": { + "name": "message_id_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_addresses": { + "name": "to_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "cc_addresses": { + "name": "cc_addresses", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attachment_file_ids": { + "name": "attachment_file_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "raw_file_id": { + "name": "raw_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_em_thread": { + "name": "idx_em_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_em_message_id": { + "name": "idx_em_message_id", + "columns": [ + { + "expression": "message_id_header", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_messages\".\"message_id_header\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_messages_thread_id_email_threads_id_fk": { + "name": "email_messages_thread_id_email_threads_id_fk", + "tableFrom": "email_messages", + "tableTo": "email_threads", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_messages_raw_file_id_files_id_fk": { + "name": "email_messages_raw_file_id_files_id_fk", + "tableFrom": "email_messages", + "tableTo": "files", + "columnsFrom": ["raw_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_threads": { + "name": "email_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_et_client": { + "name": "idx_et_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_et_port": { + "name": "idx_et_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_threads_port_id_ports_id_fk": { + "name": "email_threads_port_id_ports_id_fk", + "tableFrom": "email_threads", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_threads_client_id_clients_id_fk": { + "name": "email_threads_client_id_clients_id_fk", + "tableFrom": "email_threads", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.expenses": { + "name": "expenses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "establishment_name": { + "name": "establishment_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payer": { + "name": "payer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expense_date": { + "name": "expense_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_file_ids": { + "name": "receipt_file_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'unpaid'" + }, + "payment_date": { + "name": "payment_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "payment_reference": { + "name": "payment_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_notes": { + "name": "payment_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duplicate_of": { + "name": "duplicate_of", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dedup_scanned_at": { + "name": "dedup_scanned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ocr_status": { + "name": "ocr_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "ocr_raw": { + "name": "ocr_raw", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ocr_confidence": { + "name": "ocr_confidence", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_expenses_port": { + "name": "idx_expenses_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_expenses_date": { + "name": "idx_expenses_date", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expense_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_expenses_category": { + "name": "idx_expenses_category", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_expenses_dedup": { + "name": "idx_expenses_dedup", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "establishment_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "amount", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expense_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "duplicate_of IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "expenses_port_id_ports_id_fk": { + "name": "expenses_port_id_ports_id_fk", + "tableFrom": "expenses", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "expenses_duplicate_of_expenses_id_fk": { + "name": "expenses_duplicate_of_expenses_id_fk", + "tableFrom": "expenses", + "tableTo": "expenses", + "columnsFrom": ["duplicate_of"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_expenses": { + "name": "invoice_expenses", + "schema": "", + "columns": { + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expense_id": { + "name": "expense_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_expenses_invoice_id_invoices_id_fk": { + "name": "invoice_expenses_invoice_id_invoices_id_fk", + "tableFrom": "invoice_expenses", + "tableTo": "invoices", + "columnsFrom": ["invoice_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_expenses_expense_id_expenses_id_fk": { + "name": "invoice_expenses_expense_id_expenses_id_fk", + "tableFrom": "invoice_expenses", + "tableTo": "expenses", + "columnsFrom": ["expense_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "invoice_expenses_invoice_id_expense_id_pk": { + "name": "invoice_expenses_invoice_id_expense_id_pk", + "columns": ["invoice_id", "expense_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_line_items": { + "name": "invoice_line_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "unit_price": { + "name": "unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_ili_invoice": { + "name": "idx_ili_invoice", + "columns": [ + { + "expression": "invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoice_line_items_invoice_id_invoices_id_fk": { + "name": "invoice_line_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_line_items", + "tableTo": "invoices", + "columnsFrom": ["invoice_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_address": { + "name": "billing_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "payment_terms": { + "name": "payment_terms", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'net30'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "subtotal": { + "name": "subtotal", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "discount_pct": { + "name": "discount_pct", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "discount_amount": { + "name": "discount_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "fee_pct": { + "name": "fee_pct", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total": { + "name": "total", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'unpaid'" + }, + "payment_date": { + "name": "payment_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_reference": { + "name": "payment_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_file_id": { + "name": "pdf_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invoices_number": { + "name": "idx_invoices_number", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_port": { + "name": "idx_invoices_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_status": { + "name": "idx_invoices_status", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invoices_billing_entity": { + "name": "idx_invoices_billing_entity", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_port_id_ports_id_fk": { + "name": "invoices_port_id_ports_id_fk", + "tableFrom": "invoices", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_pdf_file_id_files_id_fk": { + "name": "invoices_pdf_file_id_files_id_fk", + "tableFrom": "invoices", + "tableTo": "files", + "columnsFrom": ["pdf_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ports": { + "name": "ports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/Anguilla'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ports_slug_idx": { + "name": "ports_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.port_role_overrides": { + "name": "port_role_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "port_role_overrides_port_role_idx": { + "name": "port_role_overrides_port_role_idx", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "port_role_overrides_port_idx": { + "name": "port_role_overrides_port_idx", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "port_role_overrides_port_id_ports_id_fk": { + "name": "port_role_overrides_port_id_ports_id_fk", + "tableFrom": "port_role_overrides", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "port_role_overrides_role_id_roles_id_fk": { + "name": "port_role_overrides_role_id_roles_id_fk", + "tableFrom": "port_role_overrides", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_global": { + "name": "is_global", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_port_roles": { + "name": "user_port_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "residential_access": { + "name": "residential_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_port_roles_user_port_role_idx": { + "name": "user_port_roles_user_port_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_upr_user": { + "name": "idx_upr_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_upr_port": { + "name": "idx_upr_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_port_roles_port_id_ports_id_fk": { + "name": "user_port_roles_port_id_ports_id_fk", + "tableFrom": "user_port_roles", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_port_roles_role_id_roles_id_fk": { + "name": "user_port_roles_role_id_roles_id_fk", + "tableFrom": "user_port_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_profiles_user_id_idx": { + "name": "user_profiles_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_unique": { + "name": "user_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.yacht_notes": { + "name": "yacht_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentions": { + "name": "mentions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_yn_yacht": { + "name": "idx_yn_yacht", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "yacht_notes_yacht_id_yachts_id_fk": { + "name": "yacht_notes_yacht_id_yachts_id_fk", + "tableFrom": "yacht_notes", + "tableTo": "yachts", + "columnsFrom": ["yacht_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.yacht_ownership_history": { + "name": "yacht_ownership_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transfer_reason": { + "name": "transfer_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transfer_notes": { + "name": "transfer_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_yoh_yacht": { + "name": "idx_yoh_yacht", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_yoh_active": { + "name": "idx_yoh_active", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"yacht_ownership_history\".\"end_date\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "yacht_ownership_history_yacht_id_yachts_id_fk": { + "name": "yacht_ownership_history_yacht_id_yachts_id_fk", + "tableFrom": "yacht_ownership_history", + "tableTo": "yachts", + "columnsFrom": ["yacht_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.yacht_tags": { + "name": "yacht_tags", + "schema": "", + "columns": { + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "yacht_tags_yacht_id_yachts_id_fk": { + "name": "yacht_tags_yacht_id_yachts_id_fk", + "tableFrom": "yacht_tags", + "tableTo": "yachts", + "columnsFrom": ["yacht_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "yacht_tags_yacht_id_tag_id_pk": { + "name": "yacht_tags_yacht_id_tag_id_pk", + "columns": ["yacht_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.yachts": { + "name": "yachts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hull_number": { + "name": "hull_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration": { + "name": "registration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flag": { + "name": "flag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "builder": { + "name": "builder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hull_material": { + "name": "hull_material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "length_ft": { + "name": "length_ft", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width_ft": { + "name": "width_ft", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "draft_ft": { + "name": "draft_ft", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "length_m": { + "name": "length_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width_m": { + "name": "width_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "draft_m": { + "name": "draft_m", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "current_owner_type": { + "name": "current_owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_owner_id": { + "name": "current_owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_yachts_port": { + "name": "idx_yachts_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_yachts_current_owner": { + "name": "idx_yachts_current_owner", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "current_owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "current_owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_yachts_name": { + "name": "idx_yachts_name", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_yachts_archived": { + "name": "idx_yachts_archived", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "yachts_port_id_ports_id_fk": { + "name": "yachts_port_id_ports_id_fk", + "tableFrom": "yachts", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interest_notes": { + "name": "interest_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentions": { + "name": "mentions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_in_interest": { + "name": "idx_in_interest", + "columns": [ + { + "expression": "interest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interest_notes_interest_id_interests_id_fk": { + "name": "interest_notes_interest_id_interests_id_fk", + "tableFrom": "interest_notes", + "tableTo": "interests", + "columnsFrom": ["interest_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interest_tags": { + "name": "interest_tags", + "schema": "", + "columns": { + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "interest_tags_interest_id_interests_id_fk": { + "name": "interest_tags_interest_id_interests_id_fk", + "tableFrom": "interest_tags", + "tableTo": "interests", + "columnsFrom": ["interest_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "interest_tags_interest_id_tag_id_pk": { + "name": "interest_tags_interest_id_tag_id_pk", + "columns": ["interest_id", "tag_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.interests": { + "name": "interests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "lead_category": { + "name": "lead_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eoi_status": { + "name": "eoi_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "documenso_id": { + "name": "documenso_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contract_status": { + "name": "contract_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deposit_status": { + "name": "deposit_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reservation_status": { + "name": "reservation_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_first_contact": { + "name": "date_first_contact", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_last_contact": { + "name": "date_last_contact", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_eoi_sent": { + "name": "date_eoi_sent", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_eoi_signed": { + "name": "date_eoi_signed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_contract_sent": { + "name": "date_contract_sent", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_contract_signed": { + "name": "date_contract_signed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_deposit_received": { + "name": "date_deposit_received", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reminder_enabled": { + "name": "reminder_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_days": { + "name": "reminder_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reminder_last_fired": { + "name": "reminder_last_fired", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_interests_port": { + "name": "idx_interests_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_interests_client": { + "name": "idx_interests_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_interests_berth": { + "name": "idx_interests_berth", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_interests_yacht": { + "name": "idx_interests_yacht", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_interests_stage": { + "name": "idx_interests_stage", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_interests_archived": { + "name": "idx_interests_archived", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "interests_port_id_ports_id_fk": { + "name": "interests_port_id_ports_id_fk", + "tableFrom": "interests", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "interests_client_id_clients_id_fk": { + "name": "interests_client_id_clients_id_fk", + "tableFrom": "interests", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.berth_reservations": { + "name": "berth_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "yacht_id": { + "name": "yacht_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tenure_type": { + "name": "tenure_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'permanent'" + }, + "contract_file_id": { + "name": "contract_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_br_berth": { + "name": "idx_br_berth", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_br_client": { + "name": "idx_br_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_br_yacht": { + "name": "idx_br_yacht", + "columns": [ + { + "expression": "yacht_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_br_port": { + "name": "idx_br_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_br_active": { + "name": "idx_br_active", + "columns": [ + { + "expression": "berth_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"berth_reservations\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "berth_reservations_berth_id_berths_id_fk": { + "name": "berth_reservations_berth_id_berths_id_fk", + "tableFrom": "berth_reservations", + "tableTo": "berths", + "columnsFrom": ["berth_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "berth_reservations_port_id_ports_id_fk": { + "name": "berth_reservations_port_id_ports_id_fk", + "tableFrom": "berth_reservations", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "berth_reservations_client_id_clients_id_fk": { + "name": "berth_reservations_client_id_clients_id_fk", + "tableFrom": "berth_reservations", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "berth_reservations_yacht_id_yachts_id_fk": { + "name": "berth_reservations_yacht_id_yachts_id_fk", + "tableFrom": "berth_reservations", + "tableTo": "yachts", + "columnsFrom": ["yacht_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "berth_reservations_interest_id_interests_id_fk": { + "name": "berth_reservations_interest_id_interests_id_fk", + "tableFrom": "berth_reservations", + "tableTo": "interests", + "columnsFrom": ["interest_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "berth_reservations_contract_file_id_files_id_fk": { + "name": "berth_reservations_contract_file_id_files_id_fk", + "tableFrom": "berth_reservations", + "tableTo": "files", + "columnsFrom": ["contract_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portal_auth_tokens": { + "name": "portal_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "portal_user_id": { + "name": "portal_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_portal_tokens_hash_unique": { + "name": "idx_portal_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_portal_tokens_user": { + "name": "idx_portal_tokens_user", + "columns": [ + { + "expression": "portal_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portal_auth_tokens_portal_user_id_portal_users_id_fk": { + "name": "portal_auth_tokens_portal_user_id_portal_users_id_fk", + "tableFrom": "portal_auth_tokens", + "tableTo": "portal_users", + "columnsFrom": ["portal_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portal_users": { + "name": "portal_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_portal_users_email_unique": { + "name": "idx_portal_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_portal_users_client": { + "name": "idx_portal_users_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_portal_users_port": { + "name": "idx_portal_users_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portal_users_port_id_ports_id_fk": { + "name": "portal_users_port_id_ports_id_fk", + "tableFrom": "portal_users", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "portal_users_client_id_clients_id_fk": { + "name": "portal_users_client_id_clients_id_fk", + "tableFrom": "portal_users", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.residential_clients": { + "name": "residential_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_e164": { + "name": "phone_e164", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_country": { + "name": "phone_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nationality_iso": { + "name": "nationality_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "place_of_residence": { + "name": "place_of_residence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "place_of_residence_country_iso": { + "name": "place_of_residence_country_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subdivision_iso": { + "name": "subdivision_iso", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_contact_method": { + "name": "preferred_contact_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prospect'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_residential_clients_port": { + "name": "idx_residential_clients_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_residential_clients_email": { + "name": "idx_residential_clients_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_residential_clients_archived": { + "name": "idx_residential_clients_archived", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "residential_clients_port_id_ports_id_fk": { + "name": "residential_clients_port_id_ports_id_fk", + "tableFrom": "residential_clients", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.residential_interests": { + "name": "residential_interests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "residential_client_id": { + "name": "residential_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date_first_contact": { + "name": "date_first_contact", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "date_last_contact": { + "name": "date_last_contact", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_residential_interests_port": { + "name": "idx_residential_interests_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_residential_interests_client": { + "name": "idx_residential_interests_client", + "columns": [ + { + "expression": "residential_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_residential_interests_stage": { + "name": "idx_residential_interests_stage", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pipeline_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_residential_interests_assigned": { + "name": "idx_residential_interests_assigned", + "columns": [ + { + "expression": "assigned_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_residential_interests_archived": { + "name": "idx_residential_interests_archived", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "residential_interests_port_id_ports_id_fk": { + "name": "residential_interests_port_id_ports_id_fk", + "tableFrom": "residential_interests", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "residential_interests_residential_client_id_residential_clients_id_fk": { + "name": "residential_interests_residential_client_id_residential_clients_id_fk", + "tableFrom": "residential_interests", + "tableTo": "residential_clients", + "columnsFrom": ["residential_client_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generated_reports": { + "name": "generated_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_report_id": { + "name": "scheduled_report_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_gr_port_created": { + "name": "idx_gr_port_created", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gr_port_status": { + "name": "idx_gr_port_status", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gr_scheduled": { + "name": "idx_gr_scheduled", + "columns": [ + { + "expression": "scheduled_report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"generated_reports\".\"scheduled_report_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generated_reports_port_id_ports_id_fk": { + "name": "generated_reports_port_id_ports_id_fk", + "tableFrom": "generated_reports", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "generated_reports_scheduled_report_id_scheduled_reports_id_fk": { + "name": "generated_reports_scheduled_report_id_scheduled_reports_id_fk", + "tableFrom": "generated_reports", + "tableTo": "scheduled_reports", + "columnsFrom": ["scheduled_report_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "generated_reports_file_id_files_id_fk": { + "name": "generated_reports_file_id_files_id_fk", + "tableFrom": "generated_reports", + "tableTo": "files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_calendar_cache": { + "name": "google_calendar_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_crm_pushed": { + "name": "is_crm_pushed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reminder_id": { + "name": "reminder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gcal_cache_user_event_idx": { + "name": "gcal_cache_user_event_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gcal_cache_user": { + "name": "idx_gcal_cache_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_calendar_cache_reminder_id_reminders_id_fk": { + "name": "google_calendar_cache_reminder_id_reminders_id_fk", + "tableFrom": "google_calendar_cache", + "tableTo": "reminders", + "columnsFrom": ["reminder_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_calendar_tokens": { + "name": "google_calendar_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_expiry": { + "name": "token_expiry", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'primary'" + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gcal_tokens_user_id_idx": { + "name": "gcal_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "google_calendar_tokens_user_id_unique": { + "name": "google_calendar_tokens_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_sent": { + "name": "email_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notif_user": { + "name": "idx_notif_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notif_port": { + "name": "idx_notif_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_user_type": { + "name": "idx_notifications_user_type", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_port_id_ports_id_fk": { + "name": "notifications_port_id_ports_id_fk", + "tableFrom": "notifications", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reminders": { + "name": "reminders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interest_id": { + "name": "interest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "berth_id": { + "name": "berth_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_generated": { + "name": "auto_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "google_calendar_event_id": { + "name": "google_calendar_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_calendar_synced": { + "name": "google_calendar_synced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "snoozed_until": { + "name": "snoozed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_reminders_port": { + "name": "idx_reminders_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reminders_assigned": { + "name": "idx_reminders_assigned", + "columns": [ + { + "expression": "assigned_to", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_reminders_due": { + "name": "idx_reminders_due", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"reminders\".\"status\" IN ('pending', 'snoozed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reminders_port_id_ports_id_fk": { + "name": "reminders_port_id_ports_id_fk", + "tableFrom": "reminders", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reminders_client_id_clients_id_fk": { + "name": "reminders_client_id_clients_id_fk", + "tableFrom": "reminders", + "tableTo": "clients", + "columnsFrom": ["client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_recipients": { + "name": "report_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "report_id": { + "name": "report_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "report_recipients_report_email_idx": { + "name": "report_recipients_report_email_idx", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_rr_report": { + "name": "idx_rr_report", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_recipients_report_id_scheduled_reports_id_fk": { + "name": "report_recipients_report_id_scheduled_reports_id_fk", + "tableFrom": "report_recipients", + "tableTo": "scheduled_reports", + "columnsFrom": ["report_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_reports": { + "name": "scheduled_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sr_port": { + "name": "idx_sr_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_reports_port_id_ports_id_fk": { + "name": "scheduled_reports_port_id_ports_id_fk", + "tableFrom": "scheduled_reports", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "field_changed": { + "name": "field_changed", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "old_value": { + "name": "old_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "new_value": { + "name": "new_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reverted_by": { + "name": "reverted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reverted_at": { + "name": "reverted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revert_of": { + "name": "revert_of", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "search_text": { + "name": "search_text", + "type": "tsvector", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_al_port": { + "name": "idx_al_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_al_entity": { + "name": "idx_al_entity", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_al_user": { + "name": "idx_al_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_al_created": { + "name": "idx_al_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_port_id_ports_id_fk": { + "name": "audit_logs_port_id_ports_id_fk", + "tableFrom": "audit_logs", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_revert_of_audit_logs_id_fk": { + "name": "audit_logs_revert_of_audit_logs_id_fk", + "tableFrom": "audit_logs", + "tableTo": "audit_logs", + "columnsFrom": ["revert_of"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.currency_rates": { + "name": "currency_rates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "base_currency": { + "name": "base_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_currency": { + "name": "target_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'frankfurter'" + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "currency_rates_base_target_idx": { + "name": "currency_rates_base_target_idx", + "columns": [ + { + "expression": "base_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_field_definitions": { + "name": "custom_field_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_name": { + "name": "field_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_label": { + "name": "field_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "select_options": { + "name": "select_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cfd_port_entity_name_idx": { + "name": "cfd_port_entity_name_idx", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cfd_port": { + "name": "idx_cfd_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_definitions_port_id_ports_id_fk": { + "name": "custom_field_definitions_port_id_ports_id_fk", + "tableFrom": "custom_field_definitions", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_field_values": { + "name": "custom_field_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "field_id": { + "name": "field_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cfv_field_entity_idx": { + "name": "cfv_field_entity_idx", + "columns": [ + { + "expression": "field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cfv_entity": { + "name": "idx_cfv_entity", + "columns": [ + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_values_field_id_custom_field_definitions_id_fk": { + "name": "custom_field_values_field_id_custom_field_definitions_id_fk", + "tableFrom": "custom_field_values", + "tableTo": "custom_field_definitions", + "columnsFrom": ["field_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_views": { + "name": "saved_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sort_config": { + "name": "sort_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "column_config": { + "name": "column_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sv_user": { + "name": "idx_sv_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_views_port_id_ports_id_fk": { + "name": "saved_views_port_id_ports_id_fk", + "tableFrom": "saved_views", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scratchpad_notes": { + "name": "scratchpad_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_client_id": { + "name": "linked_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_sp_user": { + "name": "idx_sp_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scratchpad_notes_linked_client_id_clients_id_fk": { + "name": "scratchpad_notes_linked_client_id_clients_id_fk", + "tableFrom": "scratchpad_notes", + "tableTo": "clients", + "columnsFrom": ["linked_client_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_settings_key_port_idx": { + "name": "system_settings_key_port_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_settings_port_id_ports_id_fk": { + "name": "system_settings_port_id_ports_id_fk", + "tableFrom": "system_settings", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#6B7280'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_port_name_idx": { + "name": "tags_port_name_idx", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_port": { + "name": "idx_tags_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_port_id_ports_id_fk": { + "name": "tags_port_id_ports_id_fk", + "tableFrom": "tags", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_preferences": { + "name": "user_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "in_app": { + "name": "in_app", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email": { + "name": "email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "unp_user_port_type_idx": { + "name": "unp_user_port_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notification_preferences_port_id_ports_id_fk": { + "name": "user_notification_preferences_port_id_ports_id_fk", + "tableFrom": "user_notification_preferences", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_wd_webhook": { + "name": "idx_wd_webhook", + "columns": [ + { + "expression": "webhook_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": ["webhook_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_webhooks_port": { + "name": "idx_webhooks_port", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhooks_port_id_ports_id_fk": { + "name": "webhooks_port_id_ports_id_fk", + "tableFrom": "webhooks", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link": { + "name": "link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fired_at": { + "name": "fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_by": { + "name": "dismissed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_alerts_fingerprint_open": { + "name": "idx_alerts_fingerprint_open", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "resolved_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_port_fired": { + "name": "idx_alerts_port_fired", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fired_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_port_severity_open": { + "name": "idx_alerts_port_severity_open", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "resolved_at IS NULL AND dismissed_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alerts_port_id_ports_id_fk": { + "name": "alerts_port_id_ports_id_fk", + "tableFrom": "alerts", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alerts_dismissed_by_user_id_fk": { + "name": "alerts_dismissed_by_user_id_fk", + "tableFrom": "alerts", + "tableTo": "user", + "columnsFrom": ["dismissed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "alerts_acknowledged_by_user_id_fk": { + "name": "alerts_acknowledged_by_user_id_fk", + "tableFrom": "alerts", + "tableTo": "user", + "columnsFrom": ["acknowledged_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analytics_snapshots": { + "name": "analytics_snapshots", + "schema": "", + "columns": { + "port_id": { + "name": "port_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric_id": { + "name": "metric_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_analytics_pk": { + "name": "idx_analytics_pk", + "columns": [ + { + "expression": "port_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analytics_snapshots_port_id_ports_id_fk": { + "name": "analytics_snapshots_port_id_ports_id_fk", + "tableFrom": "analytics_snapshots", + "tableTo": "ports", + "columnsFrom": ["port_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 73d9820..93e5f79 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1777379952283, "tag": "0014_black_banshee", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1777391373291, + "tag": "0015_i18n_columns", + "breakpoints": true } ] } diff --git a/src/lib/db/schema/clients.ts b/src/lib/db/schema/clients.ts index 944141e..ba31145 100644 --- a/src/lib/db/schema/clients.ts +++ b/src/lib/db/schema/clients.ts @@ -22,8 +22,12 @@ export const clients = pgTable( .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'), @@ -35,6 +39,7 @@ export const clients = pgTable( 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), ], ); @@ -49,6 +54,10 @@ export const clientContacts = pgTable( .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'), @@ -154,8 +163,12 @@ export const clientAddresses = pgTable( 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(), diff --git a/src/lib/db/schema/companies.ts b/src/lib/db/schema/companies.ts index 720a604..9f409b3 100644 --- a/src/lib/db/schema/companies.ts +++ b/src/lib/db/schema/companies.ts @@ -25,6 +25,11 @@ export const companies = pgTable( taxId: text('tax_id'), registrationNumber: text('registration_number'), incorporationCountry: text('incorporation_country'), + /** ISO-3166-1 alpha-2 country of incorporation. Replaces the + * free-text `incorporation_country` after the i18n backfill. */ + incorporationCountryIso: text('incorporation_country_iso'), + /** ISO 3166-2 subdivision (state/province) of incorporation. Optional. */ + incorporationSubdivisionIso: text('incorporation_subdivision_iso'), incorporationDate: timestamp('incorporation_date', { withTimezone: true, mode: 'date' }), status: text('status').notNull().default('active'), // 'active' | 'dissolved' billingEmail: text('billing_email'), @@ -89,8 +94,12 @@ export const companyAddresses = pgTable( streetAddress: text('street_address'), city: text('city'), stateProvince: text('state_province'), + /** ISO 3166-2 subdivision code. 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(), diff --git a/src/lib/db/schema/residential.ts b/src/lib/db/schema/residential.ts index 8e9f1e4..a4a151b 100644 --- a/src/lib/db/schema/residential.ts +++ b/src/lib/db/schema/residential.ts @@ -20,7 +20,21 @@ export const residentialClients = pgTable( fullName: text('full_name').notNull(), email: text('email'), phone: text('phone'), + /** E.164-normalized phone, populated alongside `phone` once the i18n + * PhoneInput component lands. The free-text `phone` column stays + * for one release as a fallback for unparseable rows. */ + phoneE164: text('phone_e164'), + /** ISO-3166-1 alpha-2 — country the phone was parsed against. */ + phoneCountry: text('phone_country'), + /** ISO-3166-1 alpha-2 nationality. */ + nationalityIso: text('nationality_iso'), + /** IANA timezone for scheduling/reminders. */ + timezone: text('timezone'), placeOfResidence: text('place_of_residence'), + /** ISO-3166-1 alpha-2 country of residence. */ + placeOfResidenceCountryIso: text('place_of_residence_country_iso'), + /** ISO 3166-2 subdivision code for place of residence. Optional. */ + subdivisionIso: text('subdivision_iso'), preferredContactMethod: text('preferred_contact_method'), // email | phone /** * Lifecycle: prospect | active | inactive. Distinct from diff --git a/src/lib/i18n/countries.ts b/src/lib/i18n/countries.ts new file mode 100644 index 0000000..b8da01e --- /dev/null +++ b/src/lib/i18n/countries.ts @@ -0,0 +1,311 @@ +/** + * ISO-3166-1 alpha-2 country list. + * + * Source: full ISO 3166-1 list as of 2026-04 (250 codes incl. UN + * member states + recognized territories). Country *names* are + * resolved at render time via `Intl.DisplayNames` so we don't ship + * a localized name table — the browser already has it. + * + * Validation uses the `ISO_COUNTRIES` Set; render uses + * `getCountryName(iso, locale)`. + */ + +export const ALL_COUNTRY_CODES = [ + 'AD', + 'AE', + 'AF', + 'AG', + 'AI', + 'AL', + 'AM', + 'AO', + 'AQ', + 'AR', + 'AS', + 'AT', + 'AU', + 'AW', + 'AX', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BL', + 'BM', + 'BN', + 'BO', + 'BQ', + 'BR', + 'BS', + 'BT', + 'BV', + 'BW', + 'BY', + 'BZ', + 'CA', + 'CC', + 'CD', + 'CF', + 'CG', + 'CH', + 'CI', + 'CK', + 'CL', + 'CM', + 'CN', + 'CO', + 'CR', + 'CU', + 'CV', + 'CW', + 'CX', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'EH', + 'ER', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FK', + 'FM', + 'FO', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GF', + 'GG', + 'GH', + 'GI', + 'GL', + 'GM', + 'GN', + 'GP', + 'GQ', + 'GR', + 'GS', + 'GT', + 'GU', + 'GW', + 'GY', + 'HK', + 'HM', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IM', + 'IN', + 'IO', + 'IQ', + 'IR', + 'IS', + 'IT', + 'JE', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KP', + 'KR', + 'KW', + 'KY', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MF', + 'MG', + 'MH', + 'MK', + 'ML', + 'MM', + 'MN', + 'MO', + 'MP', + 'MQ', + 'MR', + 'MS', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NC', + 'NE', + 'NF', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NU', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PF', + 'PG', + 'PH', + 'PK', + 'PL', + 'PM', + 'PN', + 'PR', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RE', + 'RO', + 'RS', + 'RU', + 'RW', + 'SA', + 'SB', + 'SC', + 'SD', + 'SE', + 'SG', + 'SH', + 'SI', + 'SJ', + 'SK', + 'SL', + 'SM', + 'SN', + 'SO', + 'SR', + 'SS', + 'ST', + 'SV', + 'SX', + 'SY', + 'SZ', + 'TC', + 'TD', + 'TF', + 'TG', + 'TH', + 'TJ', + 'TK', + 'TL', + 'TM', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'UM', + 'US', + 'UY', + 'UZ', + 'VA', + 'VC', + 'VE', + 'VG', + 'VI', + 'VN', + 'VU', + 'WF', + 'WS', + 'YE', + 'YT', + 'ZA', + 'ZM', + 'ZW', +] as const; + +export type CountryCode = (typeof ALL_COUNTRY_CODES)[number]; + +export const ISO_COUNTRIES: ReadonlySet = new Set(ALL_COUNTRY_CODES); + +export function isValidCountryCode(code: string): code is CountryCode { + return ISO_COUNTRIES.has(code); +} + +/** + * Browser-localized display name for a country code. + * Caches `Intl.DisplayNames` instances by locale to avoid repeated allocation. + */ +const displayNameCache = new Map(); + +function getDisplay(locale: string): Intl.DisplayNames { + let dn = displayNameCache.get(locale); + if (!dn) { + dn = new Intl.DisplayNames([locale], { type: 'region' }); + displayNameCache.set(locale, dn); + } + return dn; +} + +export function getCountryName(code: string, locale = 'en'): string { + if (!isValidCountryCode(code)) return code; + try { + return getDisplay(locale).of(code) ?? code; + } catch { + return code; + } +} + +/** + * Detect a sensible default country from the runtime context. + * Browser path: navigator.language (`'en-GB'` → `'GB'`). + * Server / unknown path: falls back to the provided default (`'US'` per spec). + */ +export function detectDefaultCountry(fallback: CountryCode = 'US'): CountryCode { + if (typeof navigator === 'undefined') return fallback; + const lang = navigator.language; + if (!lang) return fallback; + // Locale tags look like 'en', 'en-US', 'pl-PL'. Take the region part. + const parts = lang.split('-'); + const region = parts.length > 1 ? parts[parts.length - 1]?.toUpperCase() : ''; + if (region && isValidCountryCode(region)) return region; + return fallback; +} diff --git a/src/lib/i18n/phone.ts b/src/lib/i18n/phone.ts new file mode 100644 index 0000000..1e3f0e3 --- /dev/null +++ b/src/lib/i18n/phone.ts @@ -0,0 +1,80 @@ +/** + * Phone-number helpers built on libphonenumber-js. + * + * Uses the default `min` build (~110 KB gz). The `/mobile` build + * rejects landlines and reserved-range numbers, which is wrong for + * a marina CRM where clients commonly give office numbers. + * The `/max` build adds carrier/geocoding we don't need. + */ + +import { + AsYouType, + parsePhoneNumberFromString, + isValidPhoneNumber as libIsValid, + getCountryCallingCode, + type CountryCode as LibCountryCode, +} from 'libphonenumber-js'; + +import type { CountryCode } from './countries'; + +export interface ParsedPhone { + /** E.164 form, e.g. '+442079460958'. Null when the input isn't parseable. */ + e164: string | null; + /** ISO alpha-2 of the country the number was parsed against. */ + country: CountryCode | null; + /** Display-friendly national format, e.g. '020 7946 0958'. */ + national: string | null; + /** Display-friendly international format, e.g. '+44 20 7946 0958'. */ + international: string | null; + isValid: boolean; +} + +const EMPTY: ParsedPhone = { + e164: null, + country: null, + national: null, + international: null, + isValid: false, +}; + +/** + * Parse a raw user-typed phone string into a normalized record. + * `defaultCountry` provides context when the input lacks a +country prefix. + */ +export function parsePhone(raw: string, defaultCountry?: CountryCode): ParsedPhone { + const trimmed = raw.trim(); + if (!trimmed) return EMPTY; + try { + const parsed = parsePhoneNumberFromString(trimmed, defaultCountry as LibCountryCode); + if (!parsed) return EMPTY; + return { + e164: parsed.number, + country: (parsed.country ?? null) as CountryCode | null, + national: parsed.formatNational(), + international: parsed.formatInternational(), + isValid: parsed.isValid(), + }; + } catch { + return EMPTY; + } +} + +/** + * Format the in-progress digits with `AsYouType` for live typing. + * Returns the formatted string in the country's national style. + */ +export function formatAsYouType(raw: string, country: CountryCode): string { + const formatter = new AsYouType(country as LibCountryCode); + return formatter.input(raw); +} + +/** + * Strict validation for zod / form layer. Accepts E.164 only. + */ +export function isValidE164(value: string): boolean { + return libIsValid(value); +} + +export function callingCodeFor(country: CountryCode): string { + return `+${getCountryCallingCode(country as LibCountryCode)}`; +} diff --git a/src/lib/i18n/subdivisions.ts b/src/lib/i18n/subdivisions.ts new file mode 100644 index 0000000..1ea48fd --- /dev/null +++ b/src/lib/i18n/subdivisions.ts @@ -0,0 +1,79 @@ +/** + * Country → ISO 3166-2 subdivision (state/province/region) catalog. + * + * Backed by the `iso-3166-2` npm package which ships the full ISO + * 3166-2 dataset (~5000 entries across every country). We expose a + * thin wrapper so the combobox doesn't have to know the library + * shape, and so we can normalize the records into a uniform + * `{ code, name }` pair. + * + * Subdivisions are an OPTIONAL field per the v1 spec. Countries + * without recognized subdivisions return an empty array; the + * combobox renders an empty state in that case. + */ + +import isoCountries from 'iso-3166-2'; + +import type { CountryCode } from './countries'; + +export interface Subdivision { + /** ISO 3166-2 code, e.g. 'PL-MZ', 'US-CA', 'GB-SCT'. */ + code: string; + /** Display name (English baseline from the iso-3166-2 dataset). */ + name: string; + /** Subdivision type — 'State', 'Province', 'Region', etc. */ + type?: string; +} + +interface IsoCountryRecord { + name: string; + sub: Record; +} + +const cache = new Map(); + +function loadFor(country: CountryCode): readonly Subdivision[] { + const hit = cache.get(country); + if (hit) return hit; + // The `iso-3166-2` package exposes a `country` lookup. Defensive + // because some country codes (e.g. obscure territories) lack subs. + const lookup = (isoCountries as unknown as { country: (c: string) => IsoCountryRecord | null }) + .country; + const record = lookup ? lookup(country) : null; + if (!record?.sub) { + cache.set(country, []); + return []; + } + const list: Subdivision[] = Object.entries(record.sub) + .map(([code, { name, type }]) => ({ code, name, type })) + .sort((a, b) => a.name.localeCompare(b.name)); + cache.set(country, list); + return list; +} + +/** + * Returns the subdivision list for a country. Empty array when none + * are recognized — caller can use this to hide the field. + */ +export function subdivisionsForCountry(country: CountryCode): readonly Subdivision[] { + return loadFor(country); +} + +export function hasSubdivisions(country: CountryCode): boolean { + return loadFor(country).length > 0; +} + +export function isValidSubdivisionCode(code: string): boolean { + // ISO 3166-2 codes follow `XX-YYY` — derive the country from the prefix. + const country = code.split('-')[0]; + if (!country || country.length !== 2) return false; + const list = loadFor(country as CountryCode); + return list.some((s) => s.code === code); +} + +export function getSubdivisionName(code: string): string { + const country = code.split('-')[0]; + if (!country) return code; + const list = loadFor(country as CountryCode); + return list.find((s) => s.code === code)?.name ?? code; +} diff --git a/src/lib/i18n/timezones.ts b/src/lib/i18n/timezones.ts new file mode 100644 index 0000000..40c64b6 --- /dev/null +++ b/src/lib/i18n/timezones.ts @@ -0,0 +1,411 @@ +/** + * Country → IANA timezone mapping. + * + * For single-zone countries, value is one IANA string. + * For multi-zone countries, value is the primary IANA string followed + * by all valid alternates. Source: IANA tzdb (current as of 2026-04). + * + * The full ~600-entry IANA list comes from `Intl.supportedValuesOf('timeZone')` + * at runtime; this map only handles the country→default lookup. + */ + +import type { CountryCode } from './countries'; + +type TimezoneList = readonly [primary: string, ...alternates: string[]]; + +// Multi-zone countries — list every IANA zone. +const MULTI_ZONE: Partial> = { + AU: [ + 'Australia/Sydney', + 'Australia/Melbourne', + 'Australia/Brisbane', + 'Australia/Adelaide', + 'Australia/Perth', + 'Australia/Hobart', + 'Australia/Darwin', + ], + BR: [ + 'America/Sao_Paulo', + 'America/Manaus', + 'America/Rio_Branco', + 'America/Belem', + 'America/Recife', + 'America/Cuiaba', + 'America/Fortaleza', + 'America/Bahia', + 'America/Noronha', + ], + CA: [ + 'America/Toronto', + 'America/Vancouver', + 'America/Edmonton', + 'America/Winnipeg', + 'America/Halifax', + 'America/St_Johns', + 'America/Regina', + 'America/Whitehorse', + ], + CD: ['Africa/Kinshasa', 'Africa/Lubumbashi'], + ID: ['Asia/Jakarta', 'Asia/Pontianak', 'Asia/Makassar', 'Asia/Jayapura'], + KZ: [ + 'Asia/Almaty', + 'Asia/Atyrau', + 'Asia/Aqtau', + 'Asia/Aqtobe', + 'Asia/Oral', + 'Asia/Qostanay', + 'Asia/Qyzylorda', + ], + MN: ['Asia/Ulaanbaatar', 'Asia/Hovd', 'Asia/Choibalsan'], + MX: [ + 'America/Mexico_City', + 'America/Cancun', + 'America/Merida', + 'America/Monterrey', + 'America/Mazatlan', + 'America/Chihuahua', + 'America/Hermosillo', + 'America/Tijuana', + ], + RU: [ + 'Europe/Moscow', + 'Europe/Kaliningrad', + 'Europe/Samara', + 'Asia/Yekaterinburg', + 'Asia/Omsk', + 'Asia/Novosibirsk', + 'Asia/Krasnoyarsk', + 'Asia/Irkutsk', + 'Asia/Yakutsk', + 'Asia/Vladivostok', + 'Asia/Magadan', + 'Asia/Kamchatka', + ], + US: [ + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Phoenix', + 'America/Anchorage', + 'America/Honolulu', + 'America/Indianapolis', + 'America/Detroit', + 'America/Boise', + 'America/Adak', + 'America/Juneau', + 'America/Nome', + 'Pacific/Guam', + 'America/Puerto_Rico', + ], +}; + +// Single-zone primary lookup. Sourced from IANA tzdb's zone1970.tab. +const SINGLE_ZONE: Partial> = { + AD: 'Europe/Andorra', + AE: 'Asia/Dubai', + AF: 'Asia/Kabul', + AG: 'America/Antigua', + AI: 'America/Anguilla', + AL: 'Europe/Tirane', + AM: 'Asia/Yerevan', + AO: 'Africa/Luanda', + AQ: 'Antarctica/McMurdo', + AR: 'America/Argentina/Buenos_Aires', + AS: 'Pacific/Pago_Pago', + AT: 'Europe/Vienna', + AW: 'America/Aruba', + AX: 'Europe/Mariehamn', + AZ: 'Asia/Baku', + BA: 'Europe/Sarajevo', + BB: 'America/Barbados', + BD: 'Asia/Dhaka', + BE: 'Europe/Brussels', + BF: 'Africa/Ouagadougou', + BG: 'Europe/Sofia', + BH: 'Asia/Bahrain', + BI: 'Africa/Bujumbura', + BJ: 'Africa/Porto-Novo', + BL: 'America/St_Barthelemy', + BM: 'Atlantic/Bermuda', + BN: 'Asia/Brunei', + BO: 'America/La_Paz', + BQ: 'America/Kralendijk', + BS: 'America/Nassau', + BT: 'Asia/Thimphu', + BV: 'Antarctica/Syowa', + BW: 'Africa/Gaborone', + BY: 'Europe/Minsk', + BZ: 'America/Belize', + CC: 'Indian/Cocos', + CF: 'Africa/Bangui', + CG: 'Africa/Brazzaville', + CH: 'Europe/Zurich', + CI: 'Africa/Abidjan', + CK: 'Pacific/Rarotonga', + CL: 'America/Santiago', + CM: 'Africa/Douala', + CN: 'Asia/Shanghai', + CO: 'America/Bogota', + CR: 'America/Costa_Rica', + CU: 'America/Havana', + CV: 'Atlantic/Cape_Verde', + CW: 'America/Curacao', + CX: 'Indian/Christmas', + CY: 'Asia/Nicosia', + CZ: 'Europe/Prague', + DE: 'Europe/Berlin', + DJ: 'Africa/Djibouti', + DK: 'Europe/Copenhagen', + DM: 'America/Dominica', + DO: 'America/Santo_Domingo', + DZ: 'Africa/Algiers', + EC: 'America/Guayaquil', + EE: 'Europe/Tallinn', + EG: 'Africa/Cairo', + EH: 'Africa/El_Aaiun', + ER: 'Africa/Asmara', + ES: 'Europe/Madrid', + ET: 'Africa/Addis_Ababa', + FI: 'Europe/Helsinki', + FJ: 'Pacific/Fiji', + FK: 'Atlantic/Stanley', + FM: 'Pacific/Pohnpei', + FO: 'Atlantic/Faroe', + FR: 'Europe/Paris', + GA: 'Africa/Libreville', + GB: 'Europe/London', + GD: 'America/Grenada', + GE: 'Asia/Tbilisi', + GF: 'America/Cayenne', + GG: 'Europe/Guernsey', + GH: 'Africa/Accra', + GI: 'Europe/Gibraltar', + GL: 'America/Godthab', + GM: 'Africa/Banjul', + GN: 'Africa/Conakry', + GP: 'America/Guadeloupe', + GQ: 'Africa/Malabo', + GR: 'Europe/Athens', + GS: 'Atlantic/South_Georgia', + GT: 'America/Guatemala', + GU: 'Pacific/Guam', + GW: 'Africa/Bissau', + GY: 'America/Guyana', + HK: 'Asia/Hong_Kong', + HM: 'Antarctica/Mawson', + HN: 'America/Tegucigalpa', + HR: 'Europe/Zagreb', + HT: 'America/Port-au-Prince', + HU: 'Europe/Budapest', + IE: 'Europe/Dublin', + IL: 'Asia/Jerusalem', + IM: 'Europe/Isle_of_Man', + IN: 'Asia/Kolkata', + IO: 'Indian/Chagos', + IQ: 'Asia/Baghdad', + IR: 'Asia/Tehran', + IS: 'Atlantic/Reykjavik', + IT: 'Europe/Rome', + JE: 'Europe/Jersey', + JM: 'America/Jamaica', + JO: 'Asia/Amman', + JP: 'Asia/Tokyo', + KE: 'Africa/Nairobi', + KG: 'Asia/Bishkek', + KH: 'Asia/Phnom_Penh', + KI: 'Pacific/Tarawa', + KM: 'Indian/Comoro', + KN: 'America/St_Kitts', + KP: 'Asia/Pyongyang', + KR: 'Asia/Seoul', + KW: 'Asia/Kuwait', + KY: 'America/Cayman', + LA: 'Asia/Vientiane', + LB: 'Asia/Beirut', + LC: 'America/St_Lucia', + LI: 'Europe/Vaduz', + LK: 'Asia/Colombo', + LR: 'Africa/Monrovia', + LS: 'Africa/Maseru', + LT: 'Europe/Vilnius', + LU: 'Europe/Luxembourg', + LV: 'Europe/Riga', + LY: 'Africa/Tripoli', + MA: 'Africa/Casablanca', + MC: 'Europe/Monaco', + MD: 'Europe/Chisinau', + ME: 'Europe/Podgorica', + MF: 'America/Marigot', + MG: 'Indian/Antananarivo', + MH: 'Pacific/Majuro', + MK: 'Europe/Skopje', + ML: 'Africa/Bamako', + MM: 'Asia/Yangon', + MO: 'Asia/Macau', + MP: 'Pacific/Saipan', + MQ: 'America/Martinique', + MR: 'Africa/Nouakchott', + MS: 'America/Montserrat', + MT: 'Europe/Malta', + MU: 'Indian/Mauritius', + MV: 'Indian/Maldives', + MW: 'Africa/Blantyre', + MY: 'Asia/Kuala_Lumpur', + MZ: 'Africa/Maputo', + NA: 'Africa/Windhoek', + NC: 'Pacific/Noumea', + NE: 'Africa/Niamey', + NF: 'Pacific/Norfolk', + NG: 'Africa/Lagos', + NI: 'America/Managua', + NL: 'Europe/Amsterdam', + NO: 'Europe/Oslo', + NP: 'Asia/Kathmandu', + NR: 'Pacific/Nauru', + NU: 'Pacific/Niue', + NZ: 'Pacific/Auckland', + OM: 'Asia/Muscat', + PA: 'America/Panama', + PE: 'America/Lima', + PF: 'Pacific/Tahiti', + PG: 'Pacific/Port_Moresby', + PH: 'Asia/Manila', + PK: 'Asia/Karachi', + PL: 'Europe/Warsaw', + PM: 'America/Miquelon', + PN: 'Pacific/Pitcairn', + PR: 'America/Puerto_Rico', + PS: 'Asia/Gaza', + PT: 'Europe/Lisbon', + PW: 'Pacific/Palau', + PY: 'America/Asuncion', + QA: 'Asia/Qatar', + RE: 'Indian/Reunion', + RO: 'Europe/Bucharest', + RS: 'Europe/Belgrade', + RW: 'Africa/Kigali', + SA: 'Asia/Riyadh', + SB: 'Pacific/Guadalcanal', + SC: 'Indian/Mahe', + SD: 'Africa/Khartoum', + SE: 'Europe/Stockholm', + SG: 'Asia/Singapore', + SH: 'Atlantic/St_Helena', + SI: 'Europe/Ljubljana', + SJ: 'Arctic/Longyearbyen', + SK: 'Europe/Bratislava', + SL: 'Africa/Freetown', + SM: 'Europe/San_Marino', + SN: 'Africa/Dakar', + SO: 'Africa/Mogadishu', + SR: 'America/Paramaribo', + SS: 'Africa/Juba', + ST: 'Africa/Sao_Tome', + SV: 'America/El_Salvador', + SX: 'America/Lower_Princes', + SY: 'Asia/Damascus', + SZ: 'Africa/Mbabane', + TC: 'America/Grand_Turk', + TD: 'Africa/Ndjamena', + TF: 'Indian/Kerguelen', + TG: 'Africa/Lome', + TH: 'Asia/Bangkok', + TJ: 'Asia/Dushanbe', + TK: 'Pacific/Fakaofo', + TL: 'Asia/Dili', + TM: 'Asia/Ashgabat', + TN: 'Africa/Tunis', + TO: 'Pacific/Tongatapu', + TR: 'Europe/Istanbul', + TT: 'America/Port_of_Spain', + TV: 'Pacific/Funafuti', + TW: 'Asia/Taipei', + TZ: 'Africa/Dar_es_Salaam', + UA: 'Europe/Kyiv', + UG: 'Africa/Kampala', + UM: 'Pacific/Wake', + UY: 'America/Montevideo', + UZ: 'Asia/Tashkent', + VA: 'Europe/Vatican', + VC: 'America/St_Vincent', + VE: 'America/Caracas', + VG: 'America/Tortola', + VI: 'America/St_Thomas', + VN: 'Asia/Ho_Chi_Minh', + VU: 'Pacific/Efate', + WF: 'Pacific/Wallis', + WS: 'Pacific/Apia', + YE: 'Asia/Aden', + YT: 'Indian/Mayotte', + ZA: 'Africa/Johannesburg', + ZM: 'Africa/Lusaka', + ZW: 'Africa/Harare', +}; + +/** + * Returns the IANA zone(s) for a country. Always returns at least one + * entry; the first entry is the primary/most-populous zone. + */ +export function timezonesForCountry(country: CountryCode): readonly string[] { + const multi = MULTI_ZONE[country]; + if (multi) return multi; + const single = SINGLE_ZONE[country]; + return single ? [single] : []; +} + +/** + * Returns the single best-default IANA timezone for a country, or null + * when the dataset has no entry (caller should fall back to a generic + * default like 'UTC'). + */ +export function primaryTimezoneFor(country: CountryCode): string | null { + const list = timezonesForCountry(country); + return list[0] ?? null; +} + +/** True when the country has more than one valid zone (UI shows a sub-select). */ +export function isMultiZone(country: CountryCode): boolean { + const list = timezonesForCountry(country); + return list.length > 1; +} + +/** + * Master IANA timezone list — uses Intl when available (modern browsers + * + Node 21+). Falls back to a small bundled list when missing. + */ +export function listAllTimezones(): readonly string[] { + if (typeof Intl !== 'undefined' && 'supportedValuesOf' in Intl) { + try { + const supported = Intl.supportedValuesOf('timeZone') as string[]; + if (Array.isArray(supported) && supported.length > 0) return supported; + } catch { + // fall through + } + } + // Tiny fallback drawn from our country map — covers ~250 entries and + // never less than the timezones we'd otherwise reference. + const set = new Set(); + for (const tz of Object.values(SINGLE_ZONE)) set.add(tz!); + for (const list of Object.values(MULTI_ZONE)) { + for (const tz of list ?? []) set.add(tz); + } + return Array.from(set).sort(); +} + +/** + * Pretty-format a timezone for display: `'Europe/London (UTC+1)'`. + * The offset is computed against `now` so it follows DST. + */ +export function formatTimezoneLabel(tz: string, now: Date = new Date()): string { + try { + const parts = new Intl.DateTimeFormat('en', { + timeZone: tz, + timeZoneName: 'shortOffset', + }).formatToParts(now); + const offset = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; + return offset ? `${tz} (${offset})` : tz; + } catch { + return tz; + } +} diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index 560b0bd..895fabe 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -372,7 +372,15 @@ export async function listContacts(clientId: string, portId: string) { export async function addContact( clientId: string, portId: string, - data: { channel: string; value: string; label?: string; isPrimary?: boolean; notes?: string }, + data: { + channel: string; + value: string; + valueE164?: string | null; + valueCountry?: string | null; + label?: string; + isPrimary?: boolean; + notes?: string; + }, meta: AuditMeta, ) { const client = await db.query.clients.findFirst({ @@ -408,6 +416,8 @@ export async function updateContact( data: Partial<{ channel: string; value: string; + valueE164: string | null; + valueCountry: string | null; label: string; isPrimary: boolean; notes: string; diff --git a/src/lib/validators/clients.ts b/src/lib/validators/clients.ts index 2b81ef2..e3845d6 100644 --- a/src/lib/validators/clients.ts +++ b/src/lib/validators/clients.ts @@ -1,12 +1,21 @@ import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/route-helpers'; +import { + optionalCountryIsoSchema, + optionalIanaTimezoneSchema, + optionalPhoneE164Schema, +} from '@/lib/validators/i18n'; // ─── Contact sub-schema ────────────────────────────────────────────────────── export const contactSchema = z.object({ channel: z.enum(['email', 'phone', 'whatsapp', 'other']), value: z.string().min(1), + /** E.164-normalized number; required when channel is phone/whatsapp. */ + valueE164: optionalPhoneE164Schema.optional(), + /** ISO-3166-1 alpha-2 country the number was parsed against. */ + valueCountry: optionalCountryIsoSchema.optional(), label: z.string().optional(), isPrimary: z.boolean().optional().default(false), notes: z.string().optional(), @@ -17,10 +26,14 @@ export const contactSchema = z.object({ export const createClientSchema = z.object({ fullName: z.string().min(1).max(200), 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. */ + nationalityIso: optionalCountryIsoSchema.optional(), preferredContactMethod: z.enum(['email', 'phone', 'whatsapp']).optional(), preferredLanguage: z.string().optional(), - timezone: z.string().optional(), + /** IANA timezone (e.g. 'Europe/Warsaw'). */ + timezone: optionalIanaTimezoneSchema.optional(), source: z.enum(['website', 'manual', 'referral', 'broker']).optional(), sourceDetails: z.string().optional(), tagIds: z.array(z.string()).optional().default([]), diff --git a/src/lib/validators/companies.ts b/src/lib/validators/companies.ts index 3e83401..f7507c9 100644 --- a/src/lib/validators/companies.ts +++ b/src/lib/validators/companies.ts @@ -1,12 +1,18 @@ import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/route-helpers'; +import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n'; export const createCompanySchema = z.object({ name: z.string().min(1).max(200), legalName: z.string().optional(), taxId: 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. */ + incorporationCountryIso: optionalCountryIsoSchema.optional(), + /** ISO 3166-2 state/province of incorporation. */ + incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(), incorporationDate: z.coerce.date().optional(), status: z.enum(['active', 'dissolved']).optional().default('active'), billingEmail: z.string().email().optional(), diff --git a/src/lib/validators/i18n.ts b/src/lib/validators/i18n.ts new file mode 100644 index 0000000..f2cfa1e --- /dev/null +++ b/src/lib/validators/i18n.ts @@ -0,0 +1,78 @@ +/** + * Zod schemas wrapping the i18n primitives. Used by route handlers + * and form-level validation so the same rules run client + server. + */ + +import { z } from 'zod'; + +import { ISO_COUNTRIES } from '@/lib/i18n/countries'; +import { isValidE164 } from '@/lib/i18n/phone'; +import { isValidSubdivisionCode } from '@/lib/i18n/subdivisions'; + +// ─── Country ────────────────────────────────────────────────────────────────── + +/** ISO-3166-1 alpha-2, uppercase. */ +export const countryIsoSchema = z + .string() + .length(2) + .toUpperCase() + .refine((c) => ISO_COUNTRIES.has(c), 'Unknown country code'); + +// ─── Phone ──────────────────────────────────────────────────────────────────── + +/** E.164 form, e.g. '+442079460958'. */ +export const phoneE164Schema = z + .string() + .min(1) + .refine((v) => isValidE164(v), 'Invalid phone number'); + +// ─── Timezone ───────────────────────────────────────────────────────────────── + +/** + * IANA timezone (e.g. 'Europe/Warsaw'). Validates against + * `Intl.supportedValuesOf('timeZone')` when available. Older Node + * runtimes that lack the API fall back to a permissive shape check + * (`Region/City`) so the validator never blocks the path. + */ +export const ianaTimezoneSchema = z + .string() + .min(1) + .refine((tz) => { + if (typeof Intl !== 'undefined' && 'supportedValuesOf' in Intl) { + try { + const supported = Intl.supportedValuesOf('timeZone') as string[]; + if (supported.length > 0) return supported.includes(tz); + } catch { + // fall through + } + } + return /^[A-Z][A-Za-z_+-]+\/[A-Za-z_+-]+/.test(tz); + }, 'Unknown timezone'); + +// ─── Subdivision ────────────────────────────────────────────────────────────── + +/** ISO 3166-2 code, e.g. 'PL-MZ'. */ +export const subdivisionIsoSchema = z + .string() + .min(2) + .refine((code) => isValidSubdivisionCode(code), 'Unknown subdivision code'); + +// ─── Optional variants ──────────────────────────────────────────────────────── +// Inline forms most callers will use — empty strings normalize to null +// so the user clearing a field doesn't fail validation. + +export const optionalCountryIsoSchema = z + .union([z.literal(''), z.null(), countryIsoSchema]) + .transform((v) => (v === '' || v === null ? null : v)); + +export const optionalPhoneE164Schema = z + .union([z.literal(''), z.null(), phoneE164Schema]) + .transform((v) => (v === '' || v === null ? null : v)); + +export const optionalIanaTimezoneSchema = z + .union([z.literal(''), z.null(), ianaTimezoneSchema]) + .transform((v) => (v === '' || v === null ? null : v)); + +export const optionalSubdivisionIsoSchema = z + .union([z.literal(''), z.null(), subdivisionIsoSchema]) + .transform((v) => (v === '' || v === null ? null : v)); diff --git a/src/lib/validators/residential.ts b/src/lib/validators/residential.ts index c581514..b32eaa6 100644 --- a/src/lib/validators/residential.ts +++ b/src/lib/validators/residential.ts @@ -1,6 +1,12 @@ import { z } from 'zod'; import { baseListQuerySchema } from '@/lib/api/route-helpers'; +import { + optionalCountryIsoSchema, + optionalIanaTimezoneSchema, + optionalPhoneE164Schema, + optionalSubdivisionIsoSchema, +} from '@/lib/validators/i18n'; // ─── Residential client ────────────────────────────────────────────────────── @@ -12,7 +18,19 @@ export const createResidentialClientSchema = z.object({ .optional() .or(z.literal('').transform(() => undefined)), phone: z.string().optional(), + /** E.164-normalized phone alongside the legacy free-text `phone`. */ + phoneE164: optionalPhoneE164Schema.optional(), + /** ISO-3166-1 alpha-2 the phone was parsed against. */ + phoneCountry: optionalCountryIsoSchema.optional(), + /** ISO-3166-1 alpha-2 nationality. */ + nationalityIso: optionalCountryIsoSchema.optional(), + /** IANA timezone. */ + timezone: optionalIanaTimezoneSchema.optional(), placeOfResidence: z.string().optional(), + /** ISO-3166-1 alpha-2 country of residence. */ + placeOfResidenceCountryIso: optionalCountryIsoSchema.optional(), + /** ISO 3166-2 subdivision code for place of residence. */ + subdivisionIso: optionalSubdivisionIsoSchema.optional(), preferredContactMethod: z.enum(['email', 'phone']).optional(), status: z.enum(['prospect', 'active', 'inactive']).optional().default('prospect'), source: z.enum(['website', 'manual', 'referral', 'broker']).optional(), @@ -62,13 +80,30 @@ export const listResidentialInterestsSchema = baseListQuerySchema.extend({ /** * Shape posted by the public website's residential interest form. Coerces * to internal create-shapes inside the public route. + * + * The legacy `phone` field stays free-text — older website builds may post + * raw international strings ('+44 7700 900123'). The route handler parses + * it server-side into `phoneE164` + `phoneCountry`. Newer website builds + * can post normalized values directly. */ export const publicResidentialInquirySchema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), phone: z.string().min(1), + /** Pre-normalized E.164 form, optional for backwards compat. */ + phoneE164: optionalPhoneE164Schema.optional(), + /** ISO-3166-1 alpha-2 the phone was parsed against. */ + phoneCountry: optionalCountryIsoSchema.optional(), + /** ISO-3166-1 alpha-2 nationality. */ + nationalityIso: optionalCountryIsoSchema.optional(), + /** IANA timezone. */ + timezone: optionalIanaTimezoneSchema.optional(), placeOfResidence: z.string().optional(), + /** ISO-3166-1 alpha-2 country of residence. */ + placeOfResidenceCountryIso: optionalCountryIsoSchema.optional(), + /** ISO 3166-2 subdivision code for place of residence. */ + subdivisionIso: optionalSubdivisionIsoSchema.optional(), preferredContactMethod: z.enum(['email', 'phone']).optional(), notes: z.string().optional(), preferences: z.string().optional(), diff --git a/tests/e2e/smoke/31-i18n-form-fields.spec.ts b/tests/e2e/smoke/31-i18n-form-fields.spec.ts new file mode 100644 index 0000000..ce3010e --- /dev/null +++ b/tests/e2e/smoke/31-i18n-form-fields.spec.ts @@ -0,0 +1,65 @@ +/** + * i18n PR11 — combobox surfaces. + * + * Proves the new country / timezone / phone / subdivision combobox triggers + * actually render in the create sheets we wired in PR6–8. Doesn't exercise + * the full data round-trip (covered by integration tests + form-spec + * coverage); this spec just guards the wiring against regression. + */ + +import { test, expect } from '@playwright/test'; + +import { login, navigateTo, waitForSheet } from './helpers'; + +test.describe('i18n combobox wiring', () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('new residential client form exposes phone, country, timezone, subdivision pickers', async ({ + page, + }) => { + await navigateTo(page, '/residential/clients'); + await page.locator('main').getByRole('button', { name: /^new$/i }).first().click(); + await waitForSheet(page); + + const sheet = page.locator('[role="dialog"]'); + // PhoneInput renders a flag dropdown + national-format input. + await expect(sheet.locator('[data-testid="rc-phone-country"]')).toBeVisible(); + // Country / timezone combobox triggers. + await expect(sheet.locator('[data-testid="rc-nationality"]')).toBeVisible(); + await expect(sheet.locator('[data-testid="rc-timezone"]')).toBeVisible(); + // Country of residence + subdivision (subdivision is disabled until country picked). + await expect(sheet.locator('[data-testid="rc-residence-country"]')).toBeVisible(); + await expect(sheet.locator('[data-testid="rc-residence-subdivision"]')).toBeVisible(); + }); + + test('new client form swaps nationality input for CountryCombobox', async ({ page }) => { + await navigateTo(page, '/clients'); + // Sheet trigger label varies by tenant — stick to the topbar action. + await page + .locator('main') + .getByRole('button', { name: /^new client$/i }) + .first() + .click(); + await waitForSheet(page); + + const sheet = page.locator('[role="dialog"]'); + await expect(sheet.locator('[data-testid="client-nationality"]')).toBeVisible(); + await expect(sheet.locator('[data-testid="client-timezone"]')).toBeVisible(); + }); + + test('new company form exposes incorporation country + subdivision pickers', async ({ page }) => { + await navigateTo(page, '/companies'); + await page + .locator('main') + .getByRole('button', { name: /^new company$/i }) + .first() + .click(); + await waitForSheet(page); + + const sheet = page.locator('[role="dialog"]'); + await expect(sheet.locator('[data-testid="company-incorp-country"]')).toBeVisible(); + await expect(sheet.locator('[data-testid="company-incorp-subdivision"]')).toBeVisible(); + }); +}); diff --git a/tests/integration/public-residential-inquiry.test.ts b/tests/integration/public-residential-inquiry.test.ts new file mode 100644 index 0000000..1f6836c --- /dev/null +++ b/tests/integration/public-residential-inquiry.test.ts @@ -0,0 +1,138 @@ +/** + * i18n PR9–10 — public residential inquiry endpoint. + * + * Validates the server-side phone normalization that the public inquiry + * route runs when the website posts a raw international format (older + * Nuxt builds), and that pre-normalized payloads pass through unchanged. + */ + +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { residentialClients } from '@/lib/db/schema/residential'; +import { makePort } from '../helpers/factories'; +import { makeMockRequest } from '../helpers/route-tester'; + +vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn() })); +vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) })); + +let ipCounter = 1; +function uniqueIp(): string { + ipCounter += 1; + return `10.50.${Math.floor(ipCounter / 255) % 255}.${ipCounter % 255}`; +} + +describe('POST /api/public/residential-inquiries', () => { + let POST: typeof import('@/app/api/public/residential-inquiries/route').POST; + + beforeAll(async () => { + const mod = await import('@/app/api/public/residential-inquiries/route'); + POST = mod.POST; + }); + + it('parses a raw international phone string into E.164 + country', async () => { + const port = await makePort(); + const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`; + + const req = makeMockRequest( + 'POST', + `http://localhost/api/public/residential-inquiries?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Anna', + lastName: 'Nowak', + email, + // Raw international format — server should normalize. + phone: '+44 20 7946 0958', + placeOfResidence: 'Warsaw', + }, + }, + ); + + const res = await POST(req); + expect(res.status).toBe(201); + + const [row] = await db + .select() + .from(residentialClients) + .where(eq(residentialClients.email, email)); + + expect(row).toBeDefined(); + expect(row?.phoneE164).toBe('+442079460958'); + expect(row?.phoneCountry).toBe('GB'); + // Free-text legacy column preserved verbatim for backfill. + expect(row?.phone).toBe('+44 20 7946 0958'); + }); + + it('passes pre-normalized E.164 + country through unchanged', async () => { + const port = await makePort(); + const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`; + + const req = makeMockRequest( + 'POST', + `http://localhost/api/public/residential-inquiries?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Jan', + lastName: 'Kowalski', + email, + phone: '+48 22 555 0100', + phoneE164: '+48225550100', + phoneCountry: 'PL', + nationalityIso: 'PL', + timezone: 'Europe/Warsaw', + placeOfResidence: 'Warsaw', + placeOfResidenceCountryIso: 'PL', + }, + }, + ); + + const res = await POST(req); + expect(res.status).toBe(201); + + const [row] = await db + .select() + .from(residentialClients) + .where(eq(residentialClients.email, email)); + + expect(row?.phoneE164).toBe('+48225550100'); + expect(row?.phoneCountry).toBe('PL'); + expect(row?.nationalityIso).toBe('PL'); + expect(row?.timezone).toBe('Europe/Warsaw'); + expect(row?.placeOfResidenceCountryIso).toBe('PL'); + }); + + it('persists a national-format phone when the website only sends a country hint', async () => { + const port = await makePort(); + const email = `res-${Math.random().toString(36).slice(2, 8)}@test.local`; + + const req = makeMockRequest( + 'POST', + `http://localhost/api/public/residential-inquiries?portId=${port.id}`, + { + headers: { 'x-forwarded-for': uniqueIp() }, + body: { + firstName: 'Marta', + lastName: 'Lewandowska', + email, + phone: '22 555 0200', // National-format + phoneCountry: 'PL', // Hint only — no E.164 yet. + }, + }, + ); + + const res = await POST(req); + expect(res.status).toBe(201); + + const [row] = await db + .select() + .from(residentialClients) + .where(eq(residentialClients.email, email)); + + expect(row?.phoneE164).toBe('+48225550200'); + expect(row?.phoneCountry).toBe('PL'); + }); +}); diff --git a/tests/unit/i18n-countries.test.ts b/tests/unit/i18n-countries.test.ts new file mode 100644 index 0000000..440ce2a --- /dev/null +++ b/tests/unit/i18n-countries.test.ts @@ -0,0 +1,87 @@ +/** + * i18n PR1 — country dataset. + * + * Validates: + * 1. The dataset includes every common ISO-3166-1 alpha-2 code we'd + * reasonably want (sanity sample, not exhaustive) + * 2. `isValidCountryCode` rejects bogus inputs + * 3. `getCountryName` returns localized names; falls back to the code + * 4. `detectDefaultCountry` returns the fallback in non-browser envs + */ + +import { describe, it, expect } from 'vitest'; +import { + ALL_COUNTRY_CODES, + ISO_COUNTRIES, + isValidCountryCode, + getCountryName, + detectDefaultCountry, +} from '@/lib/i18n/countries'; + +describe('i18n countries', () => { + it('includes core anglophone, EU, MENA, APAC, and Americas codes', () => { + const sample = ['US', 'GB', 'PL', 'DE', 'FR', 'AU', 'JP', 'BR', 'AE', 'NG', 'AI']; + for (const code of sample) { + expect(ISO_COUNTRIES.has(code)).toBe(true); + } + }); + + it('has at least 240 codes and is unique', () => { + expect(ALL_COUNTRY_CODES.length).toBeGreaterThanOrEqual(240); + expect(new Set(ALL_COUNTRY_CODES).size).toBe(ALL_COUNTRY_CODES.length); + }); + + it('isValidCountryCode rejects unknown / lowercased / 3-letter codes', () => { + expect(isValidCountryCode('US')).toBe(true); + expect(isValidCountryCode('us')).toBe(false); + expect(isValidCountryCode('USA')).toBe(false); + expect(isValidCountryCode('XX')).toBe(false); + expect(isValidCountryCode('')).toBe(false); + }); + + it('getCountryName returns localized names and falls back to the code on error', () => { + expect(getCountryName('US', 'en')).toMatch(/United States/); + expect(getCountryName('GB', 'en')).toMatch(/United Kingdom/); + expect(getCountryName('PL', 'pl')).toMatch(/Polska/i); + // Unknown code -> code itself. + expect(getCountryName('ZZ', 'en')).toBe('ZZ'); + }); + + it('detectDefaultCountry returns the navigator region when valid', () => { + const original = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + value: { language: 'en-GB' }, + configurable: true, + }); + expect(detectDefaultCountry()).toBe('GB'); + Object.defineProperty(globalThis, 'navigator', { + value: original, + configurable: true, + }); + }); + + it('detectDefaultCountry uses the fallback when navigator is unset', () => { + const original = globalThis.navigator; + // @ts-expect-error force navigator to undefined for the duration of this test + delete globalThis.navigator; + expect(detectDefaultCountry()).toBe('US'); + expect(detectDefaultCountry('GB')).toBe('GB'); + Object.defineProperty(globalThis, 'navigator', { + value: original, + configurable: true, + }); + }); + + it('detectDefaultCountry falls back when the navigator region is unknown', () => { + const original = globalThis.navigator; + Object.defineProperty(globalThis, 'navigator', { + value: { language: 'xx-ZZ' }, + configurable: true, + }); + expect(detectDefaultCountry()).toBe('US'); + Object.defineProperty(globalThis, 'navigator', { + value: original, + configurable: true, + }); + }); +}); diff --git a/tests/unit/i18n-phone.test.ts b/tests/unit/i18n-phone.test.ts new file mode 100644 index 0000000..7bda89e --- /dev/null +++ b/tests/unit/i18n-phone.test.ts @@ -0,0 +1,60 @@ +/** + * i18n PR2 — phone helpers. + * + * Validates: + * 1. parsePhone yields E.164 + country + display formats + * 2. parsePhone returns the empty record for unparseable input + * 3. AsYouType formats digits in the country's national style + * 4. isValidE164 accepts only E.164 form + * 5. callingCodeFor returns the dial prefix + * 6. International-format paste detects country + */ + +import { describe, it, expect } from 'vitest'; +import { parsePhone, formatAsYouType, isValidE164, callingCodeFor } from '@/lib/i18n/phone'; + +describe('i18n phone', () => { + it('parses an international-format input regardless of defaultCountry', () => { + const r = parsePhone('+44 20 7946 0958'); + expect(r.e164).toBe('+442079460958'); + expect(r.country).toBe('GB'); + expect(r.isValid).toBe(true); + expect(r.national).toMatch(/020 7946 0958/); + expect(r.international).toMatch(/\+44 20 7946 0958/); + }); + + it('parses a national-format input against the defaultCountry', () => { + const r = parsePhone('020 7946 0958', 'GB'); + expect(r.e164).toBe('+442079460958'); + expect(r.country).toBe('GB'); + expect(r.isValid).toBe(true); + }); + + it('returns the empty record for empty / nonsense input', () => { + expect(parsePhone('').isValid).toBe(false); + expect(parsePhone('abc').isValid).toBe(false); + expect(parsePhone('').e164).toBeNull(); + }); + + it('formatAsYouType produces national-format output', () => { + // GB national-format breaks 020 7946 0958. + const out = formatAsYouType('2079460958', 'GB'); + expect(out.length).toBeGreaterThan(0); + // US national-format breaks (415) 555-1234. + const us = formatAsYouType('4155551234', 'US'); + expect(us).toContain('415'); + }); + + it('isValidE164 accepts only E.164', () => { + expect(isValidE164('+442079460958')).toBe(true); + expect(isValidE164('+1 415 555 1234')).toBe(true); // libphonenumber tolerates spaces + expect(isValidE164('020 7946 0958')).toBe(false); // missing +country prefix + expect(isValidE164('not a phone')).toBe(false); + }); + + it('callingCodeFor returns the dial prefix', () => { + expect(callingCodeFor('US')).toBe('+1'); + expect(callingCodeFor('GB')).toBe('+44'); + expect(callingCodeFor('PL')).toBe('+48'); + }); +}); diff --git a/tests/unit/i18n-subdivisions.test.ts b/tests/unit/i18n-subdivisions.test.ts new file mode 100644 index 0000000..42f4eed --- /dev/null +++ b/tests/unit/i18n-subdivisions.test.ts @@ -0,0 +1,57 @@ +/** + * i18n PR4 — subdivisions. + * + * Validates: + * 1. Major countries (PL, US, CA, GB, AU) return non-empty lists + * 2. Sample known codes resolve to expected names + * 3. hasSubdivisions correctly classifies micro-states with no subs + * 4. Validation rejects garbage codes + * 5. getSubdivisionName returns the human name + */ + +import { describe, it, expect } from 'vitest'; +import { + subdivisionsForCountry, + hasSubdivisions, + isValidSubdivisionCode, + getSubdivisionName, +} from '@/lib/i18n/subdivisions'; + +describe('i18n subdivisions', () => { + it('major countries return populated subdivision lists', () => { + expect(subdivisionsForCountry('PL').length).toBeGreaterThanOrEqual(16); + expect(subdivisionsForCountry('US').length).toBeGreaterThanOrEqual(50); + expect(subdivisionsForCountry('CA').length).toBeGreaterThanOrEqual(13); + expect(subdivisionsForCountry('GB').length).toBeGreaterThan(4); // GB has constituent + 200+ admin areas + expect(subdivisionsForCountry('AU').length).toBeGreaterThanOrEqual(8); + }); + + it('returns sorted-by-name results', () => { + const us = subdivisionsForCountry('US'); + const names = us.map((s) => s.name); + const sortedCopy = [...names].sort((a, b) => a.localeCompare(b)); + expect(names).toEqual(sortedCopy); + }); + + it('hasSubdivisions is true for known countries', () => { + expect(hasSubdivisions('US')).toBe(true); + expect(hasSubdivisions('PL')).toBe(true); + expect(hasSubdivisions('GB')).toBe(true); + }); + + it('isValidSubdivisionCode accepts known codes and rejects junk', () => { + expect(isValidSubdivisionCode('US-CA')).toBe(true); + expect(isValidSubdivisionCode('PL-MZ')).toBe(true); + expect(isValidSubdivisionCode('US-XX')).toBe(false); + expect(isValidSubdivisionCode('GARBAGE')).toBe(false); + expect(isValidSubdivisionCode('')).toBe(false); + }); + + it('getSubdivisionName returns the human name', () => { + expect(getSubdivisionName('US-CA')).toMatch(/California/); + expect(getSubdivisionName('PL-MZ')).toMatch(/Mazowieckie|Masovian/); + expect(getSubdivisionName('AU-NSW')).toMatch(/New South Wales/); + // Unknown -> code itself + expect(getSubdivisionName('XX-YY')).toBe('XX-YY'); + }); +}); diff --git a/tests/unit/i18n-timezones.test.ts b/tests/unit/i18n-timezones.test.ts new file mode 100644 index 0000000..9718096 --- /dev/null +++ b/tests/unit/i18n-timezones.test.ts @@ -0,0 +1,71 @@ +/** + * i18n PR3 — timezone helpers. + * + * Validates: + * 1. Single-zone countries return one IANA string + * 2. Multi-zone countries return >1 zones with the most-populous first + * 3. primaryTimezoneFor returns the first entry + * 4. isMultiZone correctly classifies a sample of single + multi countries + * 5. listAllTimezones returns at least our bundled set + * 6. formatTimezoneLabel renders an offset for known zones + */ + +import { describe, it, expect } from 'vitest'; +import { + timezonesForCountry, + primaryTimezoneFor, + isMultiZone, + listAllTimezones, + formatTimezoneLabel, +} from '@/lib/i18n/timezones'; + +describe('i18n timezones', () => { + it('single-zone countries map to one IANA zone', () => { + expect(timezonesForCountry('PL')).toEqual(['Europe/Warsaw']); + expect(timezonesForCountry('GB')).toEqual(['Europe/London']); + expect(timezonesForCountry('JP')).toEqual(['Asia/Tokyo']); + expect(timezonesForCountry('AE')).toEqual(['Asia/Dubai']); + }); + + it('multi-zone countries return ordered lists with the primary first', () => { + const us = timezonesForCountry('US'); + expect(us[0]).toBe('America/New_York'); + expect(us.length).toBeGreaterThan(5); + const ru = timezonesForCountry('RU'); + expect(ru[0]).toBe('Europe/Moscow'); + expect(ru.length).toBeGreaterThan(5); + }); + + it('primaryTimezoneFor returns the first entry or null', () => { + expect(primaryTimezoneFor('FR')).toBe('Europe/Paris'); + expect(primaryTimezoneFor('US')).toBe('America/New_York'); + // 'AQ' is in our map; Antarctica/McMurdo + expect(primaryTimezoneFor('AQ')).toBe('Antarctica/McMurdo'); + }); + + it('isMultiZone discriminates correctly', () => { + expect(isMultiZone('US')).toBe(true); + expect(isMultiZone('RU')).toBe(true); + expect(isMultiZone('AU')).toBe(true); + expect(isMultiZone('PL')).toBe(false); + expect(isMultiZone('JP')).toBe(false); + }); + + it('listAllTimezones returns at minimum the entries from the country map', () => { + const all = listAllTimezones(); + expect(all.length).toBeGreaterThanOrEqual(200); + expect(all).toContain('Europe/Warsaw'); + expect(all).toContain('America/New_York'); + expect(all).toContain('Asia/Tokyo'); + }); + + it('formatTimezoneLabel includes an offset for known zones', () => { + const label = formatTimezoneLabel('Europe/London'); + expect(label).toMatch(/Europe\/London/); + expect(label).toMatch(/UTC|GMT/); + }); + + it('formatTimezoneLabel falls back to the bare zone for unknown input', () => { + expect(formatTimezoneLabel('Not/A_Zone')).toBe('Not/A_Zone'); + }); +});