Files
pn-new-crm/07-DATABASE-SCHEMA.md

1095 lines
44 KiB
Markdown
Raw Normal View History

# 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
- `client_contacts` — Multi-channel contact entries per client
- `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
### Interest Domain
- `interests` — Per-berth pipeline records, each belonging to a client (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
```sql
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:
```sql
-- 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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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
```sql
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** |