Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
44 KiB
44 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 definitionsport_role_overrides— Per-port tweaks to global rolesuser_port_roles— User ↔ port ↔ role assignmentssessions— Better Auth session management
Client Domain
clients— Anchor records for people/entitiesclient_contacts— Multi-channel contact entries per clientclient_relationships— Relationships between clients (referrals, broker, family)client_notes— Timestamped notes on clientsclient_tags— Tags assigned to clientsclient_merge_log— Audit trail of client merges
Interest Domain
interests— Per-berth pipeline records, each belonging to a client (milestone dates are inline columns)interest_notes— Timestamped notes on interestsinterest_tags— Tags assigned to interests
Berth Domain
berths— Physical marina berthsberth_map_data— SVG coordinates and visual data for berth map renderingberth_recommendations— AI/manual berth recommendations for interestsberth_waiting_list— Waiting list entries per berthberth_maintenance_log— Maintenance, repair, and inspection recordsberth_tags— Tags assigned to berths
Document Domain
documents— All documents (EOIs, contracts, NDAs, custom)document_signers— Signers assigned to each documentdocument_events— Signature events and status changesdocument_templates— Reusable document templates with merge fieldsform_templates— Pre-filled data collection form definitionsform_submissions— Client form submission data
Financial Domain
expenses— Operational expenses with receipt trackinginvoices— Generated invoicesinvoice_line_items— Line items per invoiceinvoice_expenses— Junction table linking invoices to expenses
Communication Domain
email_accounts— User SMTP/IMAP connection configsemail_threads— Email conversation threads linked to clientsemail_messages— Individual email messages within threads
Operations Domain
reminders— CRM reminders and follow-ups (replaces tasks)google_calendar_tokens— Per-user Google Calendar OAuth tokensgoogle_calendar_cache— Cached Google Calendar events for displaynotifications— In-app notification recordsscheduled_reports— Report schedule configurationsreport_recipients— Recipients per scheduled report
System Domain
audit_logs— Deep audit log with before/after valuestags— Tag definitions (color, name)files— File metadata (MinIO references)webhooks— Outbound webhook configurationswebhook_deliveries— Webhook delivery logsystem_settings— Key-value system settings (per-user preferences stored inline onuser_profiles.preferences)saved_views— Saved filter/view configurationsscratchpad_notes— Per-user quick notes not tied to recordsuser_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 typecustom_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 periodeoi_reminder_settings(per-port) — EOI signing reminder schedule, cooldown, send windowcurrency_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 |