Files
pn-new-crm/07-DATABASE-SCHEMA.md
Matt Ciaccio 4da8ed3ae4
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
docs: reflect data-model refactor in CLAUDE.md + DB schema overview
PR 15 (docs): the numbered spec files mostly described the new model
already at the conceptual level, but two needed concrete updates:

- 07-DATABASE-SCHEMA.md: schema overview now lists the new Yacht /
  Company / Reservation domains alongside the existing ones, names the
  partial unique indexes (idx_yoh_active, idx_br_active) that enforce
  exclusivity, and notes that yacht/company details are no longer
  stored on `clients`.
- CLAUDE.md: the Conventions section now points future contributors at
  the new schema files, the polymorphic ownership pattern, the
  EoiContext/dual-path EOI flow, and the merge-token allow-list. Adds
  a pointer to the husky `.env*` block so it doesn't trip people up.
  References the new field-mapping doc and `assets/README.md`.

Task 15.3 (Tier 4 golden-image PDF regression) is deferred — those
tests need committed reference PDFs that come out of a real, manually
verified EOI render. Best landed once the actual `assets/eoi-template.pdf`
is in place; tracking as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:14:34 +02:00

45 KiB

Port Nimara CRM — Database Schema (PostgreSQL + Drizzle ORM)

Compiled: 2026-03-11 ORM: Drizzle ORM with TypeScript strict mode Database: PostgreSQL 16 (Docker container) Conventions: snake_case for all columns and tables, UUID primary keys, created_at/updated_at on every table, port_id on every port-scoped table, soft deletes via archived_at where applicable.


Schema Overview

Multi-Tenancy Core

  • ports — Marina ports (tenants)
  • users — CRM user accounts (managed by Better Auth)
  • roles — Permission role definitions
  • port_role_overrides — Per-port tweaks to global roles
  • user_port_roles — User ↔ port ↔ role assignments
  • sessions — Better Auth session management

Client Domain

  • clients — Anchor records for people/entities. Yacht and company details are no longer stored here — see the Yacht and Company domains.
  • client_contacts — Multi-channel contact entries per client
  • client_addresses — Physical addresses per client (primary + others)
  • client_relationships — Relationships between clients (referrals, broker, family)
  • client_notes — Timestamped notes on clients
  • client_tags — Tags assigned to clients
  • client_merge_log — Audit trail of client merges

Yacht Domain

  • yachts — First-class yacht records. Polymorphic ownership via current_owner_type ('client' | 'company') + current_owner_id.
  • yacht_ownership_history — Append-only log of every transfer; partial unique index idx_yoh_active enforces a single active owner per yacht.
  • yacht_notes, yacht_tags — Notes / tags on yachts.

Company Domain

  • companies — Legal entities that may own yachts or be billed.
  • company_addresses — Addresses per company.
  • company_memberships — Active client ↔ company links with role (director / shareholder / beneficial_owner / authorised_signatory), start/end dates.

Reservation Domain

  • berth_reservations — Concrete client + yacht + berth holds with start/end dates and status. Partial unique index idx_br_active enforces one active reservation per berth.

Interest Domain

  • interests — Per-berth pipeline records. Each row references a client_id, yacht_id (the yacht in scope for the inquiry), and optional berth_id. Milestone dates are inline columns.
  • interest_notes — Timestamped notes on interests
  • interest_tags — Tags assigned to interests

Berth Domain

  • berths — Physical marina berths
  • berth_map_data — SVG coordinates and visual data for berth map rendering
  • berth_recommendations — AI/manual berth recommendations for interests
  • berth_waiting_list — Waiting list entries per berth
  • berth_maintenance_log — Maintenance, repair, and inspection records
  • berth_tags — Tags assigned to berths

Document Domain

  • documents — All documents (EOIs, contracts, NDAs, custom)
  • document_signers — Signers assigned to each document
  • document_events — Signature events and status changes
  • document_templates — Reusable document templates with merge fields
  • form_templates — Pre-filled data collection form definitions
  • form_submissions — Client form submission data

Financial Domain

  • expenses — Operational expenses with receipt tracking
  • invoices — Generated invoices
  • invoice_line_items — Line items per invoice
  • invoice_expenses — Junction table linking invoices to expenses

Communication Domain

  • email_accounts — User SMTP/IMAP connection configs
  • email_threads — Email conversation threads linked to clients
  • email_messages — Individual email messages within threads

Operations Domain

  • reminders — CRM reminders and follow-ups (replaces tasks)
  • google_calendar_tokens — Per-user Google Calendar OAuth tokens
  • google_calendar_cache — Cached Google Calendar events for display
  • notifications — In-app notification records
  • scheduled_reports — Report schedule configurations
  • report_recipients — Recipients per scheduled report

System Domain

  • audit_logs — Deep audit log with before/after values
  • tags — Tag definitions (color, name)
  • files — File metadata (MinIO references)
  • webhooks — Outbound webhook configurations
  • webhook_deliveries — Webhook delivery log
  • system_settings — Key-value system settings (per-user preferences stored inline on user_profiles.preferences)
  • saved_views — Saved filter/view configurations
  • scratchpad_notes — Per-user quick notes not tied to records
  • user_notification_preferences — Per-user per-type notification toggles (in-app, email)
  • currency_rates — Exchange rates cache (Frankfurter API + manual overrides)
  • custom_field_definitions — Admin-defined custom fields per entity type
  • custom_field_values — Custom field data per entity record

Detailed Table Definitions

ports

CREATE TABLE ports (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name          TEXT NOT NULL,
  slug          TEXT NOT NULL UNIQUE,          -- URL-safe identifier
  logo_url      TEXT,
  primary_color TEXT,                          -- hex color for branding
  default_currency TEXT NOT NULL DEFAULT 'USD',
  timezone      TEXT NOT NULL DEFAULT 'America/Anguilla',
  settings      JSONB NOT NULL DEFAULT '{}',   -- port-specific config overrides
  is_active     BOOLEAN NOT NULL DEFAULT true,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

users

Better Auth manages the core user table. Additional CRM-specific fields:

-- Better Auth creates its own user/session tables.
-- We extend with:

CREATE TABLE user_profiles (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         TEXT NOT NULL UNIQUE,         -- references Better Auth user ID
  display_name    TEXT NOT NULL,
  avatar_url      TEXT,
  phone           TEXT,
  is_super_admin  BOOLEAN NOT NULL DEFAULT false,
  is_active       BOOLEAN NOT NULL DEFAULT true,
  last_login_at   TIMESTAMPTZ,
  preferences     JSONB NOT NULL DEFAULT '{}',  -- dark mode, locale, etc.
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

roles

CREATE TABLE roles (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name            TEXT NOT NULL,
  description     TEXT,
  permissions     JSONB NOT NULL DEFAULT '{}',  -- { "clients.view": true, "clients.create": true, ... }
  is_global       BOOLEAN NOT NULL DEFAULT true, -- available at all ports
  is_system       BOOLEAN NOT NULL DEFAULT false, -- protected from deletion (super_admin, director)
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

port_role_overrides

CREATE TABLE port_role_overrides (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id               UUID NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
  role_id               UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  permission_overrides  JSONB NOT NULL DEFAULT '{}', -- overrides specific permissions for this port
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(port_id, role_id)
);

user_port_roles

CREATE TABLE user_port_roles (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     TEXT NOT NULL,                    -- references Better Auth user ID
  port_id     UUID NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
  role_id     UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  assigned_by TEXT,                             -- user ID of who assigned this
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, port_id, role_id)
);
CREATE INDEX idx_upr_user ON user_port_roles(user_id);
CREATE INDEX idx_upr_port ON user_port_roles(port_id);

clients

CREATE TABLE clients (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id             UUID NOT NULL REFERENCES ports(id),
  full_name           TEXT NOT NULL,
  company_name        TEXT,
  nationality         TEXT,
  is_proxy            BOOLEAN NOT NULL DEFAULT false,
  proxy_type          TEXT,                      -- broker, representative, family_member, legal_counsel, other
  actual_owner_name   TEXT,                      -- if known, the real owner behind the proxy
  relationship_notes  TEXT,
  yacht_name          TEXT,
  yacht_length_ft     NUMERIC,
  yacht_width_ft      NUMERIC,
  yacht_draft_ft      NUMERIC,
  yacht_length_m      NUMERIC,
  yacht_width_m       NUMERIC,
  yacht_draft_m       NUMERIC,
  berth_size_desired  TEXT,
  preferred_contact_method TEXT,                 -- email, phone, whatsapp
  preferred_language  TEXT,
  timezone            TEXT,
  source              TEXT,                      -- website, manual, referral, broker
  source_details      TEXT,                      -- who referred, which broker, etc.
  archived_at         TIMESTAMPTZ,               -- soft delete / archive
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_clients_port ON clients(port_id);
CREATE INDEX idx_clients_name ON clients(port_id, full_name);
CREATE INDEX idx_clients_archived ON clients(port_id, archived_at);

client_contacts

CREATE TABLE client_contacts (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  client_id   UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
  channel     TEXT NOT NULL,                    -- email, phone, whatsapp, other
  value       TEXT NOT NULL,                    -- the actual address/number
  label       TEXT,                             -- primary, secondary, work, personal, broker, assistant
  is_primary  BOOLEAN NOT NULL DEFAULT false,
  notes       TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_cc_client ON client_contacts(client_id);
CREATE INDEX idx_cc_email ON client_contacts(channel, value) WHERE channel = 'email';
CREATE INDEX idx_cc_phone ON client_contacts(channel, value) WHERE channel = 'phone';

client_relationships

CREATE TABLE client_relationships (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id           UUID NOT NULL REFERENCES ports(id),
  client_a_id       UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
  client_b_id       UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
  relationship_type TEXT NOT NULL,               -- referred_by, broker_for, family_member, same_vessel, custom
  description       TEXT,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  CHECK (client_a_id != client_b_id)
);

client_merge_log

CREATE TABLE client_merge_log (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id           UUID NOT NULL REFERENCES ports(id),
  surviving_client_id UUID NOT NULL REFERENCES clients(id),
  merged_client_id  UUID NOT NULL,               -- the client that was merged away (may no longer exist)
  merged_by         TEXT NOT NULL,                -- user ID
  merge_details     JSONB NOT NULL,               -- which fields were kept from which record
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

interests

CREATE TABLE interests (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id           UUID NOT NULL REFERENCES ports(id),
  client_id         UUID NOT NULL REFERENCES clients(id),
  berth_id          UUID REFERENCES berths(id),   -- nullable for general inquiries
  pipeline_stage    TEXT NOT NULL DEFAULT 'open',  -- open, details_sent, in_communication, visited, signed_eoi_nda, deposit_10pct, contract, completed
  lead_category     TEXT,                          -- general_interest, specific_qualified, hot_lead
  source            TEXT,                          -- website, manual, referral, broker
  eoi_status        TEXT,                          -- null, waiting_for_signatures, signed, expired
  documenso_id      TEXT,                          -- Documenso document ID
  contract_status   TEXT,
  deposit_status    TEXT,
  reservation_status TEXT,
  date_first_contact TIMESTAMPTZ,
  date_last_contact  TIMESTAMPTZ,
  date_eoi_sent     TIMESTAMPTZ,
  date_eoi_signed   TIMESTAMPTZ,
  date_contract_sent TIMESTAMPTZ,
  date_contract_signed TIMESTAMPTZ,
  date_deposit_received TIMESTAMPTZ,
  reminder_enabled  BOOLEAN NOT NULL DEFAULT false,
  reminder_days     INTEGER,                       -- follow up in X days if no activity
  reminder_last_fired TIMESTAMPTZ,
  notes             TEXT,                          -- quick notes field
  archived_at       TIMESTAMPTZ,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_interests_port ON interests(port_id);
CREATE INDEX idx_interests_client ON interests(client_id);
CREATE INDEX idx_interests_berth ON interests(berth_id);
CREATE INDEX idx_interests_stage ON interests(port_id, pipeline_stage);
CREATE INDEX idx_interests_archived ON interests(port_id, archived_at);

interest_notes

CREATE TABLE interest_notes (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  interest_id   UUID NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
  author_id     TEXT NOT NULL,                    -- user ID
  content       TEXT NOT NULL,
  mentions      TEXT[],                           -- array of mentioned user IDs
  is_locked     BOOLEAN NOT NULL DEFAULT false,   -- locked after 15 min edit window
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_in_interest ON interest_notes(interest_id);

client_notes

CREATE TABLE client_notes (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  client_id     UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
  author_id     TEXT NOT NULL,
  content       TEXT NOT NULL,
  mentions      TEXT[],
  is_locked     BOOLEAN NOT NULL DEFAULT false,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_cn_client ON client_notes(client_id);

berths

CREATE TABLE berths (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id               UUID NOT NULL REFERENCES ports(id),
  mooring_number        TEXT NOT NULL,
  area                  TEXT,
  status                TEXT NOT NULL DEFAULT 'available', -- available, under_offer, sold
  length_ft             NUMERIC,
  width_ft              NUMERIC,
  draft_ft              NUMERIC,
  length_m              NUMERIC,
  width_m               NUMERIC,
  draft_m               NUMERIC,
  width_is_minimum      BOOLEAN DEFAULT false,
  nominal_boat_size     TEXT,
  nominal_boat_size_m   TEXT,
  water_depth           NUMERIC,
  water_depth_m         NUMERIC,
  water_depth_is_minimum BOOLEAN DEFAULT false,
  side_pontoon          TEXT,
  power_capacity        TEXT,
  voltage               TEXT,
  mooring_type          TEXT,
  cleat_type            TEXT,
  cleat_capacity        TEXT,
  bollard_type          TEXT,
  bollard_capacity      TEXT,
  access                TEXT,
  price                 NUMERIC,
  price_currency        TEXT NOT NULL DEFAULT 'USD',
  bow_facing            TEXT,
  berth_approved        BOOLEAN DEFAULT false,
  tenure_type           TEXT NOT NULL DEFAULT 'permanent', -- permanent, fixed_term
  tenure_years          INTEGER,                           -- for fixed_term
  tenure_start_date     DATE,
  tenure_end_date       DATE,
  status_last_changed_by TEXT,                            -- user ID who last changed status (manual or accepted suggestion)
  status_last_changed_reason TEXT,                       -- 'manual', 'rule:interest_linked', 'rule:eoi_sent', etc.
  status_last_modified  TIMESTAMPTZ,
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at            TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_berths_port ON berths(port_id);
CREATE INDEX idx_berths_status ON berths(port_id, status);
CREATE INDEX idx_berths_area ON berths(port_id, area);
CREATE UNIQUE INDEX idx_berths_mooring ON berths(port_id, mooring_number);

berth_map_data

CREATE TABLE berth_map_data (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  berth_id    UUID NOT NULL UNIQUE REFERENCES berths(id) ON DELETE CASCADE,
  svg_path    TEXT,                              -- SVG path string
  x           NUMERIC,
  y           NUMERIC,
  transform   TEXT,
  font_size   NUMERIC,
  extra_data  JSONB DEFAULT '{}',               -- any additional map rendering data
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

berth_recommendations

CREATE TABLE berth_recommendations (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  interest_id   UUID NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
  berth_id      UUID NOT NULL REFERENCES berths(id) ON DELETE CASCADE,
  match_score   NUMERIC,                        -- 0-100
  match_reasons JSONB,                          -- { "dimensional_fit": 95, "power_match": 80, ... }
  source        TEXT NOT NULL DEFAULT 'ai',     -- ai, manual
  created_by    TEXT,                            -- user ID for manual recommendations
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(interest_id, berth_id)
);

berth_waiting_list

CREATE TABLE berth_waiting_list (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  berth_id      UUID NOT NULL REFERENCES berths(id) ON DELETE CASCADE,
  client_id     UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
  position      INTEGER NOT NULL,
  priority      TEXT NOT NULL DEFAULT 'normal', -- normal, high
  notify_pref   TEXT DEFAULT 'email',           -- email, in_app, both
  notes         TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(berth_id, client_id)
);
CREATE INDEX idx_bwl_berth ON berth_waiting_list(berth_id, position);

berth_maintenance_log

CREATE TABLE berth_maintenance_log (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  berth_id        UUID NOT NULL REFERENCES berths(id) ON DELETE CASCADE,
  port_id         UUID NOT NULL REFERENCES ports(id),
  category        TEXT NOT NULL,                 -- routine, repair, inspection, upgrade
  description     TEXT NOT NULL,
  cost            NUMERIC,
  cost_currency   TEXT DEFAULT 'USD',
  responsible_party TEXT,
  performed_date  DATE NOT NULL,
  photo_file_ids  UUID[],                        -- references to files table
  created_by      TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

documents

CREATE TABLE documents (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id           UUID NOT NULL REFERENCES ports(id),
  interest_id       UUID REFERENCES interests(id),
  client_id         UUID REFERENCES clients(id),
  document_type     TEXT NOT NULL,                -- eoi, contract, nda, reservation_agreement, other
  title             TEXT NOT NULL,
  status            TEXT NOT NULL DEFAULT 'draft', -- draft, sent, partially_signed, completed, expired, cancelled
  documenso_id      TEXT,                          -- Documenso document ID (null for manual uploads)
  file_id           UUID REFERENCES files(id),     -- MinIO file reference
  signed_file_id    UUID REFERENCES files(id),     -- signed version file reference
  is_manual_upload  BOOLEAN NOT NULL DEFAULT false,
  notes             TEXT,
  created_by        TEXT NOT NULL,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_docs_port ON documents(port_id);
CREATE INDEX idx_docs_interest ON documents(interest_id);
CREATE INDEX idx_docs_client ON documents(client_id);
CREATE INDEX idx_docs_type ON documents(port_id, document_type);

document_signers

CREATE TABLE document_signers (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  signer_name     TEXT NOT NULL,
  signer_email    TEXT NOT NULL,
  signer_role     TEXT NOT NULL,                  -- client, developer, sales, approver, other
  signing_order   INTEGER NOT NULL,               -- sequential order
  status          TEXT NOT NULL DEFAULT 'pending', -- pending, signed, declined
  signed_at       TIMESTAMPTZ,
  signing_url     TEXT,
  embedded_url    TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ds_doc ON document_signers(document_id);

document_events

CREATE TABLE document_events (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id     UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  event_type      TEXT NOT NULL,                  -- created, sent, viewed, signed, completed, expired, reminder_sent
  signer_id       UUID REFERENCES document_signers(id),
  event_data      JSONB DEFAULT '{}',
  signature_hash  TEXT,                           -- deduplication
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_de_doc ON document_events(document_id);
CREATE UNIQUE INDEX idx_de_dedup ON document_events(document_id, signature_hash) WHERE signature_hash IS NOT NULL;

document_templates

CREATE TABLE document_templates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  name            TEXT NOT NULL,
  description     TEXT,
  template_type   TEXT NOT NULL,                   -- welcome_letter, handover_checklist, acknowledgment, correspondence, custom
  body_html       TEXT NOT NULL,                    -- rich text with merge field tokens: {{client.full_name}}, {{berth.mooring_number}}, etc.
  merge_fields    JSONB NOT NULL DEFAULT '[]',      -- array of field definitions: [{ "token": "client.full_name", "label": "Client Name", "source": "client" }]
  is_active       BOOLEAN NOT NULL DEFAULT true,
  created_by      TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_dt_port ON document_templates(port_id);
CREATE INDEX idx_dt_type ON document_templates(port_id, template_type);

form_templates

CREATE TABLE form_templates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  name            TEXT NOT NULL,
  description     TEXT,
  fields          JSONB NOT NULL,                 -- field definitions with types, labels, validation
  branding        JSONB DEFAULT '{}',             -- logo, colors, header text
  is_active       BOOLEAN NOT NULL DEFAULT true,
  created_by      TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

form_submissions

CREATE TABLE form_submissions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  form_template_id UUID NOT NULL REFERENCES form_templates(id),
  client_id       UUID REFERENCES clients(id),
  interest_id     UUID REFERENCES interests(id),
  token           TEXT NOT NULL UNIQUE,           -- secure token for form URL
  prefilled_data  JSONB DEFAULT '{}',
  submitted_data  JSONB,
  status          TEXT NOT NULL DEFAULT 'pending', -- pending, submitted, expired
  expires_at      TIMESTAMPTZ,
  submitted_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_fs_token ON form_submissions(token);

expenses

CREATE TABLE expenses (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id             UUID NOT NULL REFERENCES ports(id),
  establishment_name  TEXT,
  amount              NUMERIC NOT NULL,
  currency            TEXT NOT NULL DEFAULT 'USD',
  amount_usd          NUMERIC,                    -- converted equivalent
  exchange_rate       NUMERIC,                    -- rate used for conversion
  payment_method      TEXT,
  category            TEXT,
  payer               TEXT,
  expense_date        TIMESTAMPTZ NOT NULL,
  description         TEXT,
  receipt_file_ids    UUID[],                      -- references to files table
  payment_status      TEXT DEFAULT 'unpaid',       -- unpaid, paid, partial
  payment_date        DATE,
  payment_reference   TEXT,
  payment_notes       TEXT,
  created_by          TEXT NOT NULL,
  archived_at         TIMESTAMPTZ,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_expenses_port ON expenses(port_id);
CREATE INDEX idx_expenses_date ON expenses(port_id, expense_date);
CREATE INDEX idx_expenses_category ON expenses(port_id, category);

invoices

CREATE TABLE invoices (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id             UUID NOT NULL REFERENCES ports(id),
  invoice_number      TEXT NOT NULL,               -- INV-YYYYMM-### auto-generated
  client_name         TEXT NOT NULL,
  billing_email       TEXT,
  billing_address     TEXT,
  due_date            DATE NOT NULL,
  payment_terms       TEXT NOT NULL DEFAULT 'net30', -- immediate, net10, net15, net30, net45, net60
  currency            TEXT NOT NULL DEFAULT 'USD',
  subtotal            NUMERIC NOT NULL,
  discount_pct        NUMERIC DEFAULT 0,           -- e.g., 2 for net10
  discount_amount     NUMERIC DEFAULT 0,
  fee_pct             NUMERIC DEFAULT 0,           -- e.g., 5 for processing fee
  fee_amount          NUMERIC DEFAULT 0,
  total               NUMERIC NOT NULL,
  status              TEXT NOT NULL DEFAULT 'draft', -- draft, sent, paid, overdue, cancelled
  payment_status      TEXT DEFAULT 'unpaid',
  payment_date        DATE,
  payment_method      TEXT,
  payment_reference   TEXT,
  pdf_file_id         UUID REFERENCES files(id),
  notes               TEXT,
  created_by          TEXT NOT NULL,
  archived_at         TIMESTAMPTZ,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_invoices_number ON invoices(port_id, invoice_number);
CREATE INDEX idx_invoices_port ON invoices(port_id);
CREATE INDEX idx_invoices_status ON invoices(port_id, status);

invoice_line_items

CREATE TABLE invoice_line_items (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id    UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  description   TEXT NOT NULL,
  quantity      NUMERIC NOT NULL DEFAULT 1,
  unit_price    NUMERIC NOT NULL,
  total         NUMERIC NOT NULL,
  sort_order    INTEGER NOT NULL DEFAULT 0,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

invoice_expenses

CREATE TABLE invoice_expenses (
  invoice_id    UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  expense_id    UUID NOT NULL REFERENCES expenses(id) ON DELETE CASCADE,
  PRIMARY KEY (invoice_id, expense_id)
);

email_accounts

CREATE TABLE email_accounts (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         TEXT NOT NULL,
  port_id         UUID NOT NULL REFERENCES ports(id),
  provider        TEXT NOT NULL,                  -- google, outlook, custom
  email_address   TEXT NOT NULL,
  smtp_host       TEXT NOT NULL,
  smtp_port       INTEGER NOT NULL,
  imap_host       TEXT NOT NULL,
  imap_port       INTEGER NOT NULL,
  credentials_enc BYTEA NOT NULL,                 -- encrypted credentials
  is_active       BOOLEAN NOT NULL DEFAULT true,
  last_sync_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

email_threads

CREATE TABLE email_threads (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id       UUID NOT NULL REFERENCES ports(id),
  client_id     UUID REFERENCES clients(id),
  subject       TEXT,
  last_message_at TIMESTAMPTZ,
  message_count INTEGER NOT NULL DEFAULT 0,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_et_client ON email_threads(client_id);
CREATE INDEX idx_et_port ON email_threads(port_id);

email_messages

CREATE TABLE email_messages (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  thread_id       UUID NOT NULL REFERENCES email_threads(id) ON DELETE CASCADE,
  message_id_header TEXT,                        -- email Message-ID header
  from_address    TEXT NOT NULL,
  to_addresses    TEXT[] NOT NULL,
  cc_addresses    TEXT[],
  subject         TEXT,
  body_text       TEXT,
  body_html       TEXT,
  direction       TEXT NOT NULL,                 -- inbound, outbound
  sent_at         TIMESTAMPTZ NOT NULL,
  attachment_file_ids UUID[],                    -- references to files table
  raw_file_id     UUID REFERENCES files(id),     -- full raw email stored in MinIO
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_em_thread ON email_messages(thread_id);

reminders

CREATE TABLE reminders (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id             UUID NOT NULL REFERENCES ports(id),
  title               TEXT NOT NULL,
  note                TEXT,
  due_at              TIMESTAMPTZ NOT NULL,
  priority            TEXT NOT NULL DEFAULT 'medium', -- low, medium, high, urgent
  status              TEXT NOT NULL DEFAULT 'pending', -- pending, snoozed, completed, dismissed
  assigned_to         TEXT,                           -- user ID
  created_by          TEXT NOT NULL,
  client_id           UUID REFERENCES clients(id),
  interest_id         UUID REFERENCES interests(id),
  berth_id            UUID REFERENCES berths(id),
  auto_generated      BOOLEAN NOT NULL DEFAULT false, -- true if created by follow-up reminder system
  google_calendar_event_id TEXT,                      -- Google Calendar event ID (if synced)
  google_calendar_synced BOOLEAN NOT NULL DEFAULT false,
  snoozed_until       TIMESTAMPTZ,                    -- if snoozed, when to resurface
  completed_at        TIMESTAMPTZ,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_reminders_port ON reminders(port_id);
CREATE INDEX idx_reminders_assigned ON reminders(assigned_to, status);
CREATE INDEX idx_reminders_due ON reminders(port_id, due_at) WHERE status IN ('pending', 'snoozed');

google_calendar_tokens

CREATE TABLE google_calendar_tokens (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         TEXT NOT NULL UNIQUE,
  access_token    TEXT NOT NULL,                    -- encrypted (pgcrypto)
  refresh_token   TEXT NOT NULL,                    -- encrypted (pgcrypto)
  token_expiry    TIMESTAMPTZ NOT NULL,
  calendar_id     TEXT NOT NULL DEFAULT 'primary',  -- which calendar to sync
  connected_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_sync_at    TIMESTAMPTZ,
  sync_enabled    BOOLEAN NOT NULL DEFAULT true,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

google_calendar_cache

CREATE TABLE google_calendar_cache (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         TEXT NOT NULL,
  event_id        TEXT NOT NULL,                    -- Google Calendar event ID
  title           TEXT NOT NULL,
  start_at        TIMESTAMPTZ NOT NULL,
  end_at          TIMESTAMPTZ,
  location        TEXT,
  description     TEXT,
  is_crm_pushed   BOOLEAN NOT NULL DEFAULT false,   -- true if this event originated from a CRM reminder
  reminder_id     UUID REFERENCES reminders(id),     -- linked CRM reminder (if is_crm_pushed)
  fetched_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(user_id, event_id)
);
CREATE INDEX idx_gcal_cache_user ON google_calendar_cache(user_id, start_at);

notifications

CREATE TABLE notifications (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  user_id         TEXT NOT NULL,
  type            TEXT NOT NULL,                  -- reminder_due, reminder_overdue, new_registration, eoi_signed, eoi_completed, email_received, duplicate_alert, invoice_overdue, waiting_list, system_alert, follow_up_created, tenure_expiring
  title           TEXT NOT NULL,
  description     TEXT,
  link            TEXT,                           -- URL to relevant record
  entity_type     TEXT,                           -- client, interest, berth, invoice, etc.
  entity_id       UUID,
  is_read         BOOLEAN NOT NULL DEFAULT false,
  email_sent      BOOLEAN NOT NULL DEFAULT false,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_notif_user ON notifications(user_id, is_read);
CREATE INDEX idx_notif_port ON notifications(port_id);

audit_logs

CREATE TABLE audit_logs (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID REFERENCES ports(id),      -- null for system-level events
  user_id         TEXT,                            -- null for system-generated events
  action          TEXT NOT NULL,                   -- create, update, delete, archive, restore, merge, login, logout, revert
  entity_type     TEXT NOT NULL,                   -- client, interest, berth, expense, invoice, file, user, role, etc.
  entity_id       UUID,
  field_changed   TEXT,                            -- specific field for update actions
  old_value       JSONB,
  new_value       JSONB,
  ip_address      TEXT,
  user_agent      TEXT,
  reverted_by     TEXT,                            -- user ID if this change was reverted
  reverted_at     TIMESTAMPTZ,
  revert_of       UUID REFERENCES audit_logs(id),  -- points to the audit entry this reverts
  metadata        JSONB DEFAULT '{}',              -- extra context
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_al_port ON audit_logs(port_id, created_at DESC);
CREATE INDEX idx_al_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_al_user ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_al_created ON audit_logs(created_at DESC);

tags

CREATE TABLE tags (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id     UUID NOT NULL REFERENCES ports(id),
  name        TEXT NOT NULL,
  color       TEXT NOT NULL DEFAULT '#6B7280',   -- hex color
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(port_id, name)
);

client_tags

CREATE TABLE client_tags (
  client_id   UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
  tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (client_id, tag_id)
);

interest_tags

CREATE TABLE interest_tags (
  interest_id UUID NOT NULL REFERENCES interests(id) ON DELETE CASCADE,
  tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (interest_id, tag_id)
);

berth_tags

CREATE TABLE berth_tags (
  berth_id    UUID NOT NULL REFERENCES berths(id) ON DELETE CASCADE,
  tag_id      UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  PRIMARY KEY (berth_id, tag_id)
);

files

CREATE TABLE files (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  client_id       UUID REFERENCES clients(id),
  filename        TEXT NOT NULL,
  original_name   TEXT NOT NULL,
  mime_type       TEXT,
  size_bytes      BIGINT,
  storage_path    TEXT NOT NULL,                   -- MinIO object key
  storage_bucket  TEXT NOT NULL DEFAULT 'crm-files',
  category        TEXT,                            -- eoi, contract, image, receipt, correspondence, misc
  uploaded_by     TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_files_port ON files(port_id);
CREATE INDEX idx_files_client ON files(client_id);

webhooks

CREATE TABLE webhooks (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  name            TEXT NOT NULL,
  url             TEXT NOT NULL,
  secret          TEXT,                            -- HMAC signing secret
  events          TEXT[] NOT NULL,                  -- array of event types to subscribe to
  is_active       BOOLEAN NOT NULL DEFAULT true,
  created_by      TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

webhook_deliveries

CREATE TABLE webhook_deliveries (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  webhook_id      UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
  event_type      TEXT NOT NULL,
  payload         JSONB NOT NULL,
  response_status INTEGER,
  response_body   TEXT,
  attempt         INTEGER NOT NULL DEFAULT 1,
  status          TEXT NOT NULL DEFAULT 'pending', -- pending, success, failed, dead_letter
  delivered_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_wd_webhook ON webhook_deliveries(webhook_id, created_at DESC);

scheduled_reports

CREATE TABLE scheduled_reports (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  name            TEXT NOT NULL,
  report_type     TEXT NOT NULL,                   -- pipeline_summary, expense_summary, berth_occupancy, activity_log, overdue_items, revenue_forecast
  schedule        TEXT NOT NULL,                   -- cron expression
  last_run_at     TIMESTAMPTZ,
  next_run_at     TIMESTAMPTZ,
  is_active       BOOLEAN NOT NULL DEFAULT true,
  config          JSONB DEFAULT '{}',              -- report-specific config options
  created_by      TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

report_recipients

CREATE TABLE report_recipients (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  report_id       UUID NOT NULL REFERENCES scheduled_reports(id) ON DELETE CASCADE,
  email           TEXT NOT NULL,
  user_id         TEXT,                            -- null for external recipients
  UNIQUE(report_id, email)
);

system_settings

CREATE TABLE system_settings (
  key         TEXT PRIMARY KEY,
  value       JSONB NOT NULL,
  port_id     UUID REFERENCES ports(id),          -- null for global settings
  updated_by  TEXT,
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(key, port_id)
);

Key system_settings entries include:

  • berth_status_rules (per-port) — JSON array of { trigger, mode, target_status } objects defining berth auto-status transition behavior. Modes: auto, suggest, off. See BR-001.
  • follow_up_defaults (per-port) — Default reminder_days, send window hours, cooldown period
  • eoi_reminder_settings (per-port) — EOI signing reminder schedule, cooldown, send window
  • currency_display (global) — Primary currency, secondary currency, decimal places

saved_views

CREATE TABLE saved_views (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  user_id         TEXT NOT NULL,
  entity_type     TEXT NOT NULL,                   -- clients, interests, berths, expenses, invoices
  name            TEXT NOT NULL,
  filters         JSONB NOT NULL,
  sort_config     JSONB,
  column_config   JSONB,                           -- which columns visible, column order
  is_shared       BOOLEAN NOT NULL DEFAULT false,
  is_default      BOOLEAN NOT NULL DEFAULT false,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sv_user ON saved_views(user_id, entity_type);

scratchpad_notes

CREATE TABLE scratchpad_notes (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         TEXT NOT NULL,
  content         TEXT NOT NULL,
  linked_client_id UUID REFERENCES clients(id),    -- null until dragged onto a client
  linked_at       TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sp_user ON scratchpad_notes(user_id);

user_notification_preferences

CREATE TABLE user_notification_preferences (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         TEXT NOT NULL,
  port_id         UUID NOT NULL REFERENCES ports(id),
  notification_type TEXT NOT NULL,
  in_app          BOOLEAN NOT NULL DEFAULT true,
  email           BOOLEAN NOT NULL DEFAULT true,
  UNIQUE(user_id, port_id, notification_type)
);

currency_rates

CREATE TABLE currency_rates (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  base_currency   TEXT NOT NULL,
  target_currency TEXT NOT NULL,
  rate            NUMERIC NOT NULL,
  source          TEXT NOT NULL DEFAULT 'frankfurter', -- frankfurter, manual
  fetched_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(base_currency, target_currency)
);

custom_fields

CREATE TABLE custom_field_definitions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  port_id         UUID NOT NULL REFERENCES ports(id),
  entity_type     TEXT NOT NULL,                   -- client, interest, berth
  field_name      TEXT NOT NULL,
  field_label     TEXT NOT NULL,
  field_type      TEXT NOT NULL,                   -- text, number, date, boolean, select
  select_options  JSONB,                           -- for select type: ["option1", "option2"]
  is_required     BOOLEAN NOT NULL DEFAULT false,
  sort_order      INTEGER NOT NULL DEFAULT 0,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(port_id, entity_type, field_name)
);

CREATE TABLE custom_field_values (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  field_id        UUID NOT NULL REFERENCES custom_field_definitions(id) ON DELETE CASCADE,
  entity_id       UUID NOT NULL,                   -- references the client/interest/berth ID
  value           JSONB NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(field_id, entity_id)
);
CREATE INDEX idx_cfv_entity ON custom_field_values(entity_id);

Table Count Summary

Category Tables Count
Multi-Tenancy Core ports, user_profiles, roles, port_role_overrides, user_port_roles 5
Client Domain clients, client_contacts, client_relationships, client_merge_log, client_notes, client_tags 6
Interest Domain interests, interest_notes, interest_tags 3
Berth Domain berths, berth_map_data, berth_recommendations, berth_waiting_list, berth_maintenance_log, berth_tags 6
Document Domain documents, document_signers, document_events, document_templates, form_templates, form_submissions 6
Financial Domain expenses, invoices, invoice_line_items, invoice_expenses 4
Communication Domain email_accounts, email_threads, email_messages 3
Operations Domain reminders, google_calendar_tokens, google_calendar_cache, notifications, scheduled_reports, report_recipients 6
System Domain audit_logs, tags, files, webhooks, webhook_deliveries, system_settings, saved_views, scratchpad_notes, user_notification_preferences, currency_rates, custom_field_definitions, custom_field_values 12
TOTAL 51 tables