Skip to content

πŸ—„οΈ Database DesignΒΆ

Complete database architecture and schema design for the Property Management System, optimized for multi-tenancy, scalability, and performance.

πŸ—οΈ Database Architecture OverviewΒΆ

Multi-Tenant StrategyΒΆ

graph TB
    subgraph "Schema-Based Multi-Tenancy"
        PS[Public Schema]
        TS1[Tenant Schema 1]
        TS2[Tenant Schema 2]
        TSN[Tenant Schema N]
    end

    subgraph "Shared Resources"
        PS --> Auth[Authentication Tables]
        PS --> Sys[System Configuration]
        PS --> Audit[Audit Logs]
    end

    subgraph "Tenant-Specific Data"
        TS1 --> Prop1[Properties]
        TS1 --> Ten1[Tenants]
        TS1 --> Fin1[Financial Data]

        TS2 --> Prop2[Properties]
        TS2 --> Ten2[Tenants]
        TS2 --> Fin2[Financial Data]
    end

    App[Application Layer] --> PS
    App --> TS1
    App --> TS2
    App --> TSN

Technology StackΒΆ

  • Primary Database: PostgreSQL 15+ with multi-schema support
  • Caching Layer: Redis for session management and frequently accessed data
  • Connection Pooling: PgBouncer for connection management
  • Backup Strategy: WAL-E for continuous archiving and point-in-time recovery

πŸ“Š Schema Design StrategyΒΆ

Public Schema (Shared)ΒΆ

Contains system-wide data that applies to all tenants:

System TablesΒΆ

-- System configuration and metadata
public.system_configuration
public.tenant_registry
public.feature_flags
public.audit_logs
public.scheduled_jobs

Authentication & AuthorizationΒΆ

-- User authentication (references tenant-specific user data)
public.auth_users
public.auth_sessions
public.auth_tokens
public.role_permissions
public.api_keys

Tenant Schemas (Isolated)ΒΆ

Each organization gets its own schema with complete data isolation:

Schema Naming ConventionΒΆ

  • Format: org_[organization_id]
  • Example: org_123, org_456
  • Benefits: Clear isolation, easy backup/restore per tenant

🏒 Core Entity Design¢

Organization & User ManagementΒΆ

Organizations TableΒΆ

-- org_123.organizations
CREATE TABLE organizations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    display_name VARCHAR(255),
    domain VARCHAR(100),
    subscription_plan VARCHAR(50),
    max_properties INTEGER DEFAULT 10,
    max_users INTEGER DEFAULT 50,
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Users TableΒΆ

-- org_123.users
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id),
    email VARCHAR(255) UNIQUE NOT NULL,
    phone_number VARCHAR(20),
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    role VARCHAR(50) NOT NULL,
    status VARCHAR(20) DEFAULT 'active',
    preferences JSONB DEFAULT '{}',
    last_login_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Property ManagementΒΆ

Properties HierarchyΒΆ

-- org_123.properties
CREATE TABLE properties (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES organizations(id),
    name VARCHAR(255) NOT NULL,
    type VARCHAR(50) NOT NULL, -- 'residential', 'commercial', 'industrial'
    address JSONB NOT NULL, -- Full address object
    total_units INTEGER DEFAULT 0,
    property_manager_id UUID REFERENCES users(id),
    financial_settings JSONB DEFAULT '{}',
    amenities TEXT[],
    policies JSONB DEFAULT '{}',
    status VARCHAR(20) DEFAULT 'active',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- org_123.buildings (for multi-building properties)
CREATE TABLE buildings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    floors INTEGER DEFAULT 1,
    units_per_floor INTEGER DEFAULT 1,
    amenities TEXT[],
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- org_123.units
CREATE TABLE units (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
    building_id UUID REFERENCES buildings(id) ON DELETE CASCADE,
    unit_number VARCHAR(50) NOT NULL,
    floor INTEGER,
    type VARCHAR(50), -- 'studio', '1br', '2br', etc.
    square_feet DECIMAL(8,2),
    bedrooms INTEGER DEFAULT 0,
    bathrooms DECIMAL(3,1) DEFAULT 0,
    rent_amount DECIMAL(10,2),
    status VARCHAR(20) DEFAULT 'vacant', -- 'vacant', 'occupied', 'maintenance'
    amenities TEXT[],
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),

    UNIQUE(property_id, unit_number)
);

Tenant & Lease ManagementΒΆ

TenantsΒΆ

-- org_123.tenants
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    property_id UUID NOT NULL REFERENCES properties(id),
    unit_id UUID REFERENCES units(id),
    emergency_contact JSONB,
    employment_info JSONB,
    references JSONB,
    background_check_status VARCHAR(20),
    move_in_date DATE,
    status VARCHAR(20) DEFAULT 'active',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

LeasesΒΆ

-- org_123.leases
CREATE TABLE leases (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    unit_id UUID NOT NULL REFERENCES units(id),
    lease_type VARCHAR(20) DEFAULT 'fixed', -- 'fixed', 'month-to-month'
    start_date DATE NOT NULL,
    end_date DATE,
    rent_amount DECIMAL(10,2) NOT NULL,
    security_deposit DECIMAL(10,2),
    late_fee_amount DECIMAL(10,2) DEFAULT 0,
    lease_terms JSONB DEFAULT '{}',
    status VARCHAR(20) DEFAULT 'active',
    signed_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Financial ManagementΒΆ

Chart of AccountsΒΆ

-- org_123.accounts
CREATE TABLE accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    account_code VARCHAR(20) UNIQUE NOT NULL,
    account_name VARCHAR(255) NOT NULL,
    account_type VARCHAR(20) NOT NULL, -- 'asset', 'liability', 'income', 'expense'
    parent_account_id UUID REFERENCES accounts(id),
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

TransactionsΒΆ

-- org_123.transactions
CREATE TABLE transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id UUID REFERENCES properties(id),
    unit_id UUID REFERENCES units(id),
    tenant_id UUID REFERENCES tenants(id),
    transaction_type VARCHAR(20) NOT NULL, -- 'income', 'expense'
    account_id UUID NOT NULL REFERENCES accounts(id),
    amount DECIMAL(12,2) NOT NULL,
    description TEXT,
    transaction_date DATE NOT NULL,
    due_date DATE,
    payment_method VARCHAR(50),
    reference_number VARCHAR(100),
    status VARCHAR(20) DEFAULT 'pending',
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Maintenance ManagementΒΆ

Work OrdersΒΆ

-- org_123.work_orders
CREATE TABLE work_orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id UUID NOT NULL REFERENCES properties(id),
    unit_id UUID REFERENCES units(id),
    tenant_id UUID REFERENCES tenants(id),
    created_by_user_id UUID NOT NULL REFERENCES users(id),
    assigned_to_user_id UUID REFERENCES users(id),
    title VARCHAR(255) NOT NULL,
    description TEXT,
    category VARCHAR(50), -- 'plumbing', 'electrical', 'hvac', etc.
    priority VARCHAR(20) DEFAULT 'medium', -- 'low', 'medium', 'high', 'urgent'
    status VARCHAR(20) DEFAULT 'open',
    estimated_cost DECIMAL(10,2),
    actual_cost DECIMAL(10,2),
    scheduled_date TIMESTAMP WITH TIME ZONE,
    completed_date TIMESTAMP WITH TIME ZONE,
    tenant_accessible BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Communication SystemΒΆ

MessagesΒΆ

-- org_123.messages
CREATE TABLE messages (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sender_id UUID NOT NULL REFERENCES users(id),
    recipient_type VARCHAR(20) NOT NULL, -- 'user', 'property', 'unit'
    recipient_id UUID NOT NULL,
    subject VARCHAR(255),
    content TEXT NOT NULL,
    message_type VARCHAR(20) DEFAULT 'message', -- 'message', 'announcement', 'alert'
    priority VARCHAR(20) DEFAULT 'normal',
    read_at TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

πŸ” Indexing StrategyΒΆ

Performance-Critical IndexesΒΆ

-- Property-based queries (most common)
CREATE INDEX idx_units_property_status ON org_123.units(property_id, status);
CREATE INDEX idx_tenants_property_status ON org_123.tenants(property_id, status);
CREATE INDEX idx_transactions_property_date ON org_123.transactions(property_id, transaction_date DESC);
CREATE INDEX idx_work_orders_property_status ON org_123.work_orders(property_id, status);

-- User-based queries
CREATE INDEX idx_users_org_role ON org_123.users(organization_id, role);
CREATE INDEX idx_users_email ON org_123.users(email);

-- Time-based queries for reporting
CREATE INDEX idx_transactions_date_type ON org_123.transactions(transaction_date DESC, transaction_type);
CREATE INDEX idx_work_orders_created_status ON org_123.work_orders(created_at DESC, status);

-- Search and filtering
CREATE INDEX idx_properties_name_gin ON org_123.properties USING gin(to_tsvector('english', name));
CREATE INDEX idx_units_number ON org_123.units(unit_number);

Composite Indexes for Complex QueriesΒΆ

-- Financial reporting
CREATE INDEX idx_transactions_property_date_type_status 
ON org_123.transactions(property_id, transaction_date, transaction_type, status);

-- Maintenance dashboard
CREATE INDEX idx_work_orders_assigned_status_priority 
ON org_123.work_orders(assigned_to_user_id, status, priority);

-- Tenant management
CREATE INDEX idx_leases_unit_dates ON org_123.leases(unit_id, start_date, end_date);

πŸ”’ Security & Data IntegrityΒΆ

Row Level Security (RLS)ΒΆ

-- Enable RLS on all tenant tables
ALTER TABLE org_123.properties ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_123.units ENABLE ROW LEVEL SECURITY;
ALTER TABLE org_123.tenants ENABLE ROW LEVEL SECURITY;

-- Example RLS policy for property managers
CREATE POLICY property_manager_access ON org_123.properties
FOR ALL TO property_manager_role
USING (property_manager_id = current_user_id());

-- Tenant can only see their own data
CREATE POLICY tenant_own_data ON org_123.tenants
FOR ALL TO tenant_role
USING (user_id = current_user_id());

Data EncryptionΒΆ

  • At Rest: PostgreSQL TDE (Transparent Data Encryption)
  • In Transit: SSL/TLS connections with certificate validation
  • Application Level: Sensitive fields encrypted using AES-256
  • Key Management: AWS KMS or Azure Key Vault integration

Audit TrailΒΆ

-- org_123.audit_log
CREATE TABLE audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    table_name VARCHAR(100) NOT NULL,
    record_id UUID NOT NULL,
    action VARCHAR(20) NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
    old_values JSONB,
    new_values JSONB,
    user_id UUID,
    ip_address INET,
    user_agent TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Audit trigger function
CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit_log(table_name, record_id, action, new_values, user_id)
        VALUES (TG_TABLE_NAME, NEW.id, TG_OP, to_jsonb(NEW), current_user_id());
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO audit_log(table_name, record_id, action, old_values, new_values, user_id)
        VALUES (TG_TABLE_NAME, NEW.id, TG_OP, to_jsonb(OLD), to_jsonb(NEW), current_user_id());
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO audit_log(table_name, record_id, action, old_values, user_id)
        VALUES (TG_TABLE_NAME, OLD.id, TG_OP, to_jsonb(OLD), current_user_id());
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

πŸ“ˆ Scalability ConsiderationsΒΆ

Horizontal ScalingΒΆ

  • Read Replicas: Dedicated read replicas for reporting queries
  • Sharding Strategy: Shard by organization_id for extreme scale
  • Connection Pooling: PgBouncer with per-tenant connection limits

Vertical ScalingΒΆ

  • Hardware Specs:
  • CPU: 8-16 cores minimum for production
  • RAM: 32-64GB with shared_buffers = 25% of RAM
  • Storage: NVMe SSD with 10,000+ IOPS
  • PostgreSQL Settings:
    shared_buffers = 8GB
    effective_cache_size = 24GB
    work_mem = 256MB
    maintenance_work_mem = 2GB
    max_connections = 400
    

Archival StrategyΒΆ

-- Archive old transactions (>2 years)
CREATE TABLE org_123.transactions_archive (LIKE org_123.transactions);

-- Automated archival function
CREATE OR REPLACE FUNCTION archive_old_transactions() RETURNS void AS $$
BEGIN
    INSERT INTO org_123.transactions_archive
    SELECT * FROM org_123.transactions 
    WHERE created_at < NOW() - INTERVAL '2 years';

    DELETE FROM org_123.transactions 
    WHERE created_at < NOW() - INTERVAL '2 years';
END;
$$ LANGUAGE plpgsql;

πŸ”„ Data Migration & BackupΒΆ

Backup StrategyΒΆ

  1. Continuous WAL Archival: Stream WAL files to S3/Azure Blob
  2. Daily Full Backups: pg_dump for each tenant schema
  3. Point-in-Time Recovery: Ability to restore to any point in last 30 days
  4. Cross-Region Replication: Disaster recovery replicas

Migration ScriptsΒΆ

-- Schema migration template
-- V001__initial_schema.sql
CREATE SCHEMA IF NOT EXISTS org_${tenant_id};

-- Create tables in correct order (dependencies first)
-- 1. Organizations
-- 2. Users  
-- 3. Properties
-- 4. Buildings & Units
-- 5. Tenants & Leases
-- 6. Transactions
-- 7. Work Orders
-- 8. Messages

-- Create indexes
-- Create triggers
-- Insert default data

Data Import/ExportΒΆ

-- Export tenant data
COPY (
    SELECT row_to_json(t) FROM (
        SELECT * FROM org_123.properties
        UNION ALL
        SELECT * FROM org_123.units
        -- ... all tables
    ) t
) TO '/path/to/export.json';

-- Import tenant data with validation
CREATE OR REPLACE FUNCTION import_tenant_data(data JSONB) 
RETURNS void AS $$
-- Validation and import logic
$$ LANGUAGE plpgsql;

πŸ“Š Performance MonitoringΒΆ

Key Metrics to MonitorΒΆ

  • Query Performance: Slow query log analysis
  • Connection Usage: Active connections per tenant
  • Cache Hit Ratio: Buffer cache and shared memory usage
  • Disk I/O: Read/write operations per second
  • Replication Lag: Lag between primary and replicas

Monitoring QueriesΒΆ

-- Find slow queries
SELECT query, mean_time, calls, total_time 
FROM pg_stat_statements 
WHERE mean_time > 100 
ORDER BY mean_time DESC;

-- Check index usage
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
WHERE idx_scan < 100
ORDER BY idx_scan;

-- Monitor connection usage
SELECT datname, usename, application_name, state, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY datname, usename, application_name, state;

πŸš€ Development WorkflowΒΆ

Environment SetupΒΆ

  1. Local Development: Docker Compose with PostgreSQL + Redis
  2. Testing: Dedicated test database with sample data
  3. Staging: Production-like environment with anonymized data
  4. Production: High-availability cluster with monitoring

Schema VersioningΒΆ

  • Migration Files: Numbered SQL files (V001, V002, etc.)
  • Rollback Scripts: Corresponding rollback for each migration
  • Testing: Automated tests for each migration
  • Deployment: Blue-green deployment with schema validation

Database Best Practices

  • Always use transactions for multi-table operations
  • Implement proper connection pooling and timeouts
  • Monitor query performance and optimize regularly
  • Use prepared statements to prevent SQL injection
  • Implement proper backup and disaster recovery procedures

Next Steps

πŸ—„οΈ Complete Database Design & Implementation GuideΒΆ

Property Management System - Database Architecture SpecificationΒΆ

Complete database implementation specification for developers and database administrators building the Property Management System, including schema design, multi-tenancy implementation, performance optimization, security policies, and operational procedures.


πŸ“‹ Table of ContentsΒΆ


Database Architecture OverviewΒΆ

System PhilosophyΒΆ

The Property Management System uses a Schema-Based Multi-Tenancy approach with PostgreSQL 15+, providing: - Complete Data Isolation: Each organization operates in its own schema - ACID Compliance: Full transactional support across all business domains - Cross-Schema Relationships: Native foreign key support between schemas - Simplified Operations: Single database instance for backup, monitoring, and maintenance - Future Flexibility: Schemas can be extracted to separate databases when scaling demands it

Technology StackΒΆ

graph TB
    subgraph "Database Layer"
        PG15["PostgreSQL 15+<br/>Primary Database<br/>Multi-Schema Support"]
        Redis["Redis 7+<br/>Session Storage<br/>Application Cache"]
        Elasticsearch["Elasticsearch<br/>Full-text Search<br/>Analytics Engine"]
    end

    subgraph "Connection Management"
        PgBouncer["PgBouncer<br/>Connection Pooling<br/>Per-Tenant Limits"]
        ConnPool["Connection Pool<br/>Shared Across Services<br/>Organization Context"]
    end

    subgraph "Backup & Recovery"
        WAL["WAL Archival<br/>Continuous Backup<br/>Point-in-Time Recovery"]
        Replica["Read Replicas<br/>Reporting Queries<br/>Disaster Recovery"]
    end

    subgraph "Monitoring"
        Prometheus["Prometheus<br/>Performance Metrics<br/>Alert Management"]
        Grafana["Grafana<br/>Database Dashboards<br/>Query Analytics"]
    end

    PG15 --> PgBouncer
    PgBouncer --> ConnPool
    PG15 --> WAL
    PG15 --> Replica
    PG15 --> Prometheus
    Prometheus --> Grafana

Database StructureΒΆ

PropertyManagementDB (Single PostgreSQL Database)
β”œβ”€β”€ πŸ—‚οΈ identity (Authentication & Authorization)
β”‚   β”œβ”€β”€ users, auth_sessions, auth_tokens
β”‚   β”œβ”€β”€ roles, permissions, user_claims
β”‚   └── oauth_providers, mfa_settings
β”œβ”€β”€ πŸ—‚οΈ core (Organizations, Properties, Users)
β”‚   β”œβ”€β”€ organizations, user_profiles, properties
β”‚   β”œβ”€β”€ buildings, units, tenants, leases
β”‚   └── property_managers, organization_members
β”œβ”€β”€ πŸ—‚οΈ financial (Billing, Payments, Expenses)
β”‚   β”œβ”€β”€ invoices, payments, transactions
β”‚   β”œβ”€β”€ accounts, budgets, financial_reports
β”‚   └── payment_methods, billing_cycles
β”œβ”€β”€ πŸ—‚οΈ operations (Maintenance, Vendors, Work Orders)
β”‚   β”œβ”€β”€ maintenance_requests, work_orders, vendors
β”‚   β”œβ”€β”€ maintenance_staff, vendor_services
β”‚   └── assets, preventive_maintenance, inspections
β”œβ”€β”€ πŸ—‚οΈ communication (Notifications, Messages)
β”‚   β”œβ”€β”€ notifications, messages, announcements
β”‚   β”œβ”€β”€ email_templates, notification_preferences
β”‚   └── communication_logs, delivery_status
└── πŸ—‚οΈ shared (Lookups, Audit Logs, Settings)
    β”œβ”€β”€ countries, states, currencies, time_zones
    β”œβ”€β”€ audit_logs, system_settings, feature_flags
    └── scheduled_jobs, system_configuration

Multi-Tenant StrategyΒΆ

Schema-Based Separation PatternΒΆ

Organization Context Implementation: All database connections set the organization context at the session level to ensure automatic data isolation through Row-Level Security policies.

-- Set organization context for session
SET application_name = 'property_management';
SET row_security = on;
SELECT set_config('app.current_organization_id', '123e4567-e89b-12d3-a456-426614174000', true);

Data Isolation PatternsΒΆ

Pattern Implementation Schema Usage Example
Organization Context organization_id UUID in all business tables core, financial, operations, communication properties.organization_id = current_organization()
Shared Reference Data No tenant context in lookup tables shared schema countries, states, currencies
User Context Users can belong to multiple organizations identity + core schemas Cross-tenant user access with role mapping
Audit Trail All changes logged with tenant context shared schema Compliance tracking and debugging

Row-Level Security (RLS) ImplementationΒΆ

-- Enable RLS on all tenant-specific tables
ALTER TABLE core.properties ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.units ENABLE ROW LEVEL SECURITY;
ALTER TABLE financial.invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE operations.maintenance_requests ENABLE ROW LEVEL SECURITY;

-- Organization isolation policy (applied to all tenant tables)
CREATE OR REPLACE FUNCTION current_organization_id() RETURNS UUID AS $$
BEGIN
    RETURN COALESCE(
        current_setting('app.current_organization_id', true)::UUID,
        '00000000-0000-0000-0000-000000000000'::UUID
    );
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

-- Example RLS policy for properties
CREATE POLICY organization_isolation ON core.properties
FOR ALL
USING (organization_id = current_organization_id());

-- Property manager access policy
CREATE POLICY property_manager_access ON core.properties
FOR ALL TO property_manager_role
USING (
    organization_id = current_organization_id() AND
    (property_manager_id = auth.uid() OR 
     auth.uid() IN (
         SELECT user_id FROM core.organization_members 
         WHERE organization_id = current_organization_id() 
         AND role IN ('admin', 'super_admin')
     ))
);

-- Tenant access policy
CREATE POLICY tenant_access ON core.properties
FOR SELECT TO tenant_role
USING (
    organization_id = current_organization_id() AND
    id IN (
        SELECT property_id FROM core.units u
        JOIN core.tenants t ON u.id = t.unit_id
        WHERE t.user_id = auth.uid()
    )
);

Schema Design & Data ModelsΒΆ

Identity Service Schema (Authentication & Authorization)ΒΆ

-- Create identity schema
CREATE SCHEMA IF NOT EXISTS identity;

-- User profiles (core identity information)
CREATE TABLE identity.user_profiles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    external_auth_id VARCHAR(255) UNIQUE, -- OAuth provider ID
    email VARCHAR(255) UNIQUE NOT NULL,
    email_verified BOOLEAN DEFAULT false,
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    phone_number VARCHAR(20),
    phone_verified BOOLEAN DEFAULT false,
    profile_image_url TEXT,
    language_preference VARCHAR(10) DEFAULT 'en',
    timezone VARCHAR(50) DEFAULT 'UTC',
    mfa_enabled BOOLEAN DEFAULT false,
    mfa_secret VARCHAR(255),
    last_login_at TIMESTAMPTZ,
    login_count INTEGER DEFAULT 0,
    status VARCHAR(20) DEFAULT 'active', -- active, inactive, suspended, banned
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Authentication sessions
CREATE TABLE identity.auth_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES identity.user_profiles(id) ON DELETE CASCADE,
    token_hash VARCHAR(255) NOT NULL,
    refresh_token_hash VARCHAR(255),
    device_id VARCHAR(255),
    device_name VARCHAR(255),
    ip_address INET,
    user_agent TEXT,
    expires_at TIMESTAMPTZ NOT NULL,
    last_activity_at TIMESTAMPTZ DEFAULT NOW(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- User roles and permissions
CREATE TABLE identity.user_roles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES identity.user_profiles(id) ON DELETE CASCADE,
    entity_id UUID NOT NULL, -- organization_id, property_id, etc.
    entity_type VARCHAR(50) NOT NULL, -- organization, property, unit
    role_name VARCHAR(50) NOT NULL, -- admin, property_manager, tenant, etc.
    permissions JSONB DEFAULT '[]', -- array of permission strings
    granted_by UUID REFERENCES identity.user_profiles(id),
    granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ,
    is_active BOOLEAN DEFAULT true,

    UNIQUE(user_id, entity_id, entity_type, role_name)
);

-- OAuth provider configurations
CREATE TABLE identity.oauth_providers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    provider_name VARCHAR(50) NOT NULL UNIQUE, -- google, microsoft, azure_ad
    client_id VARCHAR(255) NOT NULL,
    client_secret VARCHAR(255) NOT NULL, -- encrypted
    authorization_url TEXT NOT NULL,
    token_url TEXT NOT NULL,
    user_info_url TEXT NOT NULL,
    scopes TEXT[] DEFAULT ARRAY['openid', 'profile', 'email'],
    is_enabled BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- API keys for service-to-service authentication
CREATE TABLE identity.api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    key_hash VARCHAR(255) NOT NULL UNIQUE,
    organization_id UUID, -- NULL for system-wide keys
    permissions JSONB DEFAULT '[]',
    expires_at TIMESTAMPTZ,
    last_used_at TIMESTAMPTZ,
    is_active BOOLEAN DEFAULT true,
    created_by UUID REFERENCES identity.user_profiles(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Create indexes for identity schema
CREATE INDEX idx_user_profiles_email ON identity.user_profiles(email);
CREATE INDEX idx_user_profiles_external_auth ON identity.user_profiles(external_auth_id);
CREATE INDEX idx_user_profiles_status ON identity.user_profiles(status);
CREATE INDEX idx_auth_sessions_user_id ON identity.auth_sessions(user_id);
CREATE INDEX idx_auth_sessions_token ON identity.auth_sessions(token_hash);
CREATE INDEX idx_auth_sessions_expires ON identity.auth_sessions(expires_at);
CREATE INDEX idx_user_roles_user_entity ON identity.user_roles(user_id, entity_id, entity_type);
CREATE INDEX idx_user_roles_entity ON identity.user_roles(entity_id, entity_type);
CREATE INDEX idx_api_keys_hash ON identity.api_keys(key_hash);
CREATE INDEX idx_api_keys_org ON identity.api_keys(organization_id);

Core Service Schema (Organizations, Properties, Users)ΒΆ

-- Create core schema
CREATE SCHEMA IF NOT EXISTS core;

-- Organizations (multi-tenant isolation)
CREATE TABLE core.organizations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    display_name VARCHAR(255),
    slug VARCHAR(100) UNIQUE NOT NULL, -- URL-friendly identifier
    domain VARCHAR(100), -- Custom domain for organization
    logo_url TEXT,
    primary_color VARCHAR(7), -- Hex color for branding
    subscription_plan VARCHAR(50) DEFAULT 'basic', -- basic, professional, enterprise
    subscription_status VARCHAR(20) DEFAULT 'active', -- active, suspended, cancelled
    max_properties INTEGER DEFAULT 10,
    max_users INTEGER DEFAULT 50,
    max_storage_gb INTEGER DEFAULT 10,
    billing_email VARCHAR(255),
    support_email VARCHAR(255),
    phone_number VARCHAR(20),
    address JSONB, -- Full address object
    timezone VARCHAR(50) DEFAULT 'UTC',
    currency VARCHAR(3) DEFAULT 'USD',
    language VARCHAR(10) DEFAULT 'en',
    settings JSONB DEFAULT '{}', -- Organization-specific settings
    onboarding_completed BOOLEAN DEFAULT false,
    trial_ends_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- User profiles within organizations
CREATE TABLE core.user_profiles (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    identity_user_id UUID NOT NULL REFERENCES identity.user_profiles(id),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    employee_id VARCHAR(50), -- Organization-specific employee ID
    department VARCHAR(100),
    job_title VARCHAR(100),
    manager_id UUID REFERENCES core.user_profiles(id),
    hire_date DATE,
    emergency_contact JSONB, -- Name, phone, relationship
    work_location VARCHAR(255),
    skills TEXT[], -- Array of skills
    certifications JSONB, -- Certification details
    preferences JSONB DEFAULT '{}', -- User preferences within org
    status VARCHAR(20) DEFAULT 'active', -- active, inactive, on_leave
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(identity_user_id, organization_id)
);

-- Organization membership tracking
CREATE TABLE core.organization_members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    user_profile_id UUID NOT NULL REFERENCES core.user_profiles(id) ON DELETE CASCADE,
    role VARCHAR(50) NOT NULL, -- admin, manager, staff, readonly
    invited_by UUID REFERENCES core.user_profiles(id),
    invited_at TIMESTAMPTZ,
    joined_at TIMESTAMPTZ DEFAULT NOW(),
    status VARCHAR(20) DEFAULT 'active', -- pending, active, inactive
    permissions JSONB DEFAULT '[]',

    UNIQUE(organization_id, user_profile_id)
);

-- Properties
CREATE TABLE core.properties (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    property_type VARCHAR(50) NOT NULL, -- residential, commercial, industrial, mixed
    subtype VARCHAR(50), -- apartment, condo, house, office, retail, etc.
    status VARCHAR(20) DEFAULT 'active', -- active, inactive, under_construction, sold

    -- Address information
    address_line1 VARCHAR(255) NOT NULL,
    address_line2 VARCHAR(255),
    city VARCHAR(100) NOT NULL,
    state_province VARCHAR(100) NOT NULL,
    postal_code VARCHAR(20),
    country VARCHAR(2) NOT NULL, -- ISO country code
    latitude DECIMAL(10, 7),
    longitude DECIMAL(10, 7),

    -- Property details
    year_built INTEGER,
    total_area_sqft DECIMAL(10, 2),
    lot_size_sqft DECIMAL(10, 2),
    total_units INTEGER DEFAULT 0,
    total_floors INTEGER DEFAULT 1,
    parking_spaces INTEGER DEFAULT 0,

    -- Management information
    property_manager_id UUID REFERENCES core.user_profiles(id),
    acquisition_date DATE,
    acquisition_cost DECIMAL(15, 2),
    current_market_value DECIMAL(15, 2),
    annual_property_tax DECIMAL(10, 2),
    annual_insurance DECIMAL(10, 2),

    -- Features and amenities
    amenities TEXT[], -- Array of amenities
    features TEXT[], -- Property features
    appliances TEXT[], -- Included appliances

    -- Policy and rules
    pet_policy JSONB, -- Pet rules and restrictions
    smoking_policy VARCHAR(20) DEFAULT 'no_smoking',
    guest_policy JSONB, -- Guest and visitor policies

    -- Financial settings
    late_fee_amount DECIMAL(8, 2) DEFAULT 0,
    late_fee_percentage DECIMAL(5, 2) DEFAULT 0,
    grace_period_days INTEGER DEFAULT 5,

    -- Media and documents
    images TEXT[], -- Array of image URLs
    documents TEXT[], -- Array of document URLs
    virtual_tour_url TEXT,

    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Buildings (for multi-building properties)
CREATE TABLE core.buildings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id UUID NOT NULL REFERENCES core.properties(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    building_number VARCHAR(20),
    address VARCHAR(500), -- If different from main property
    floors INTEGER DEFAULT 1,
    units_per_floor INTEGER DEFAULT 1,
    total_units INTEGER DEFAULT 0,
    elevator_count INTEGER DEFAULT 0,
    stairwell_count INTEGER DEFAULT 1,
    amenities TEXT[],
    year_built INTEGER,
    construction_type VARCHAR(50),
    heating_type VARCHAR(50),
    cooling_type VARCHAR(50),
    roof_type VARCHAR(50),
    exterior_material VARCHAR(50),
    images TEXT[],
    floor_plans TEXT[], -- Array of floor plan URLs
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(property_id, building_number)
);

-- Units
CREATE TABLE core.units (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id UUID NOT NULL REFERENCES core.properties(id) ON DELETE CASCADE,
    building_id UUID REFERENCES core.buildings(id) ON DELETE CASCADE,
    unit_number VARCHAR(50) NOT NULL,
    unit_type VARCHAR(50), -- studio, 1br, 2br, 3br, commercial, etc.
    floor INTEGER,
    square_feet DECIMAL(8, 2),
    bedrooms INTEGER DEFAULT 0,
    bathrooms DECIMAL(3, 1) DEFAULT 0,
    half_baths INTEGER DEFAULT 0,

    -- Rental information
    market_rent DECIMAL(10, 2),
    current_rent DECIMAL(10, 2),
    security_deposit DECIMAL(10, 2),
    pet_deposit DECIMAL(8, 2),

    -- Unit status
    status VARCHAR(20) DEFAULT 'vacant', -- vacant, occupied, maintenance, unavailable
    availability_date DATE,
    lease_start_date DATE,
    lease_end_date DATE,

    -- Unit features
    features TEXT[], -- Unit-specific features
    appliances TEXT[], -- Included appliances
    flooring_type VARCHAR(50),
    view_type VARCHAR(50),
    balcony_patio BOOLEAN DEFAULT false,
    fireplace BOOLEAN DEFAULT false,
    in_unit_laundry BOOLEAN DEFAULT false,

    -- Utilities
    utilities_included TEXT[], -- Heat, electric, water, etc.
    utility_allowance DECIMAL(8, 2),

    -- Parking
    assigned_parking_spaces INTEGER DEFAULT 0,
    parking_space_numbers TEXT[],
    garage_spaces INTEGER DEFAULT 0,

    -- Media
    images TEXT[],
    floor_plan_url TEXT,
    virtual_tour_url TEXT,

    -- Maintenance
    last_renovated DATE,
    condition_rating INTEGER CHECK (condition_rating >= 1 AND condition_rating <= 5),
    maintenance_notes TEXT,

    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(property_id, unit_number)
);

-- Tenants
CREATE TABLE core.tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    user_profile_id UUID REFERENCES core.user_profiles(id), -- If tenant has system access
    property_id UUID NOT NULL REFERENCES core.properties(id),
    unit_id UUID REFERENCES core.units(id),

    -- Personal information (for non-system users)
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    email VARCHAR(255),
    phone_primary VARCHAR(20),
    phone_secondary VARCHAR(20),
    date_of_birth DATE,

    -- Emergency contacts
    emergency_contact_1 JSONB, -- name, relationship, phone, email
    emergency_contact_2 JSONB,

    -- Employment information
    employer_name VARCHAR(255),
    job_title VARCHAR(100),
    annual_income DECIMAL(12, 2),
    employment_verification JSONB, -- verification details

    -- Previous rental history
    previous_address JSONB,
    previous_landlord JSONB,
    rental_history JSONB, -- Array of previous rentals

    -- Tenant status
    status VARCHAR(20) DEFAULT 'active', -- prospect, approved, active, notice_given, moved_out
    move_in_date DATE,
    move_out_date DATE,
    notice_date DATE,

    -- Background check and screening
    credit_score INTEGER,
    background_check_status VARCHAR(20), -- pending, passed, failed, expired
    background_check_date DATE,
    screening_notes TEXT,

    -- Preferences and notes
    communication_preference VARCHAR(20) DEFAULT 'email', -- email, sms, phone, mail
    language_preference VARCHAR(10) DEFAULT 'en',
    special_needs TEXT,
    notes TEXT,

    -- Pets
    pets JSONB, -- Array of pet information

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Leases
CREATE TABLE core.leases (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    tenant_id UUID NOT NULL REFERENCES core.tenants(id),
    unit_id UUID NOT NULL REFERENCES core.units(id),
    property_id UUID NOT NULL REFERENCES core.properties(id),

    -- Lease terms
    lease_type VARCHAR(20) DEFAULT 'fixed', -- fixed, month_to_month, weekly
    start_date DATE NOT NULL,
    end_date DATE,
    rent_amount DECIMAL(10, 2) NOT NULL,
    rent_due_day INTEGER DEFAULT 1, -- Day of month rent is due

    -- Deposits and fees
    security_deposit DECIMAL(10, 2) DEFAULT 0,
    pet_deposit DECIMAL(8, 2) DEFAULT 0,
    cleaning_deposit DECIMAL(8, 2) DEFAULT 0,
    key_deposit DECIMAL(6, 2) DEFAULT 0,

    -- Late fees
    late_fee_amount DECIMAL(8, 2) DEFAULT 0,
    late_fee_percentage DECIMAL(5, 2) DEFAULT 0,
    grace_period_days INTEGER DEFAULT 5,

    -- Lease clauses and terms
    lease_terms JSONB DEFAULT '{}', -- Custom lease terms
    utilities_included TEXT[],
    pets_allowed BOOLEAN DEFAULT false,
    smoking_allowed BOOLEAN DEFAULT false,
    subletting_allowed BOOLEAN DEFAULT false,
    modifications_allowed BOOLEAN DEFAULT false,

    -- Lease status and tracking
    status VARCHAR(20) DEFAULT 'draft', -- draft, active, expired, terminated, renewed
    signed_by_tenant_at TIMESTAMPTZ,
    signed_by_landlord_at TIMESTAMPTZ,
    move_in_inspection_id UUID,
    move_out_inspection_id UUID,

    -- Renewal information
    auto_renew BOOLEAN DEFAULT false,
    renewal_notice_days INTEGER DEFAULT 60,
    rent_increase_percentage DECIMAL(5, 2) DEFAULT 0,

    -- Documents
    lease_document_url TEXT,
    signed_lease_url TEXT,
    addendums TEXT[], -- Array of addendum URLs

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Create indexes for core schema
CREATE INDEX idx_organizations_slug ON core.organizations(slug);
CREATE INDEX idx_organizations_status ON core.organizations(subscription_status);
CREATE INDEX idx_user_profiles_org ON core.user_profiles(organization_id);
CREATE INDEX idx_user_profiles_identity ON core.user_profiles(identity_user_id);
CREATE INDEX idx_organization_members_org ON core.organization_members(organization_id);
CREATE INDEX idx_properties_org ON core.properties(organization_id);
CREATE INDEX idx_properties_manager ON core.properties(property_manager_id);
CREATE INDEX idx_properties_type ON core.properties(property_type, status);
CREATE INDEX idx_properties_location ON core.properties(city, state_province, country);
CREATE INDEX idx_buildings_property ON core.buildings(property_id);
CREATE INDEX idx_units_property ON core.units(property_id);
CREATE INDEX idx_units_building ON core.units(building_id);
CREATE INDEX idx_units_status ON core.units(status);
CREATE INDEX idx_units_availability ON core.units(availability_date) WHERE status = 'vacant';
CREATE INDEX idx_tenants_org ON core.tenants(organization_id);
CREATE INDEX idx_tenants_property ON core.tenants(property_id);
CREATE INDEX idx_tenants_unit ON core.tenants(unit_id);
CREATE INDEX idx_tenants_status ON core.tenants(status);
CREATE INDEX idx_tenants_email ON core.tenants(email);
CREATE INDEX idx_leases_org ON core.leases(organization_id);
CREATE INDEX idx_leases_tenant ON core.leases(tenant_id);
CREATE INDEX idx_leases_unit ON core.leases(unit_id);
CREATE INDEX idx_leases_dates ON core.leases(start_date, end_date);
CREATE INDEX idx_leases_status ON core.leases(status);

Financial Service SchemaΒΆ

-- Create financial schema
CREATE SCHEMA IF NOT EXISTS financial;

-- Chart of accounts
CREATE TABLE financial.accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    account_code VARCHAR(20) NOT NULL,
    account_name VARCHAR(255) NOT NULL,
    account_type VARCHAR(20) NOT NULL, -- asset, liability, income, expense, equity
    account_category VARCHAR(50), -- cash, accounts_receivable, rent_income, etc.
    parent_account_id UUID REFERENCES financial.accounts(id),
    is_active BOOLEAN DEFAULT true,
    is_system_account BOOLEAN DEFAULT false, -- Cannot be deleted
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(organization_id, account_code)
);

-- Invoices (rent, fees, utilities, etc.)
CREATE TABLE financial.invoices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    property_id UUID NOT NULL REFERENCES core.properties(id),
    unit_id UUID REFERENCES core.units(id),
    tenant_id UUID NOT NULL REFERENCES core.tenants(id),
    lease_id UUID REFERENCES core.leases(id),

    -- Invoice details
    invoice_number VARCHAR(50) NOT NULL,
    invoice_type VARCHAR(20) NOT NULL, -- rent, late_fee, utility, maintenance, other
    description TEXT,

    -- Amounts
    subtotal DECIMAL(12, 2) NOT NULL,
    tax_amount DECIMAL(10, 2) DEFAULT 0,
    discount_amount DECIMAL(10, 2) DEFAULT 0,
    total_amount DECIMAL(12, 2) NOT NULL,
    paid_amount DECIMAL(12, 2) DEFAULT 0,
    balance_due DECIMAL(12, 2) NOT NULL,

    -- Dates
    invoice_date DATE NOT NULL,
    due_date DATE NOT NULL,
    period_start DATE, -- For recurring charges
    period_end DATE,

    -- Status and tracking
    status VARCHAR(20) DEFAULT 'pending', -- pending, sent, partial, paid, overdue, cancelled
    sent_at TIMESTAMPTZ,
    payment_terms INTEGER DEFAULT 30, -- Days

    -- Late fees
    late_fee_applied BOOLEAN DEFAULT false,
    late_fee_amount DECIMAL(8, 2) DEFAULT 0,
    late_fee_date DATE,

    -- References
    recurring_invoice_id UUID REFERENCES financial.invoices(id), -- For recurring charges
    external_reference VARCHAR(100),

    -- Notifications
    reminder_sent_count INTEGER DEFAULT 0,
    last_reminder_sent_at TIMESTAMPTZ,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(organization_id, invoice_number)
);

-- Invoice line items
CREATE TABLE financial.invoice_line_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE,
    account_id UUID NOT NULL REFERENCES financial.accounts(id),
    description TEXT NOT NULL,
    quantity DECIMAL(10, 2) DEFAULT 1,
    unit_price DECIMAL(10, 2) NOT NULL,
    line_total DECIMAL(10, 2) NOT NULL,
    tax_rate DECIMAL(5, 4) DEFAULT 0,
    tax_amount DECIMAL(8, 2) DEFAULT 0,
    sort_order INTEGER DEFAULT 0
);

-- Payments
CREATE TABLE financial.payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    tenant_id UUID NOT NULL REFERENCES core.tenants(id),
    property_id UUID NOT NULL REFERENCES core.properties(id),
    unit_id UUID REFERENCES core.units(id),

    -- Payment details
    payment_number VARCHAR(50), -- Generated payment reference
    payment_method VARCHAR(20) NOT NULL, -- cash, check, credit_card, bank_transfer, ach, etc.
    payment_type VARCHAR(20) DEFAULT 'rent', -- rent, deposit, fee, refund, etc.

    -- Amounts
    payment_amount DECIMAL(12, 2) NOT NULL,
    fee_amount DECIMAL(8, 2) DEFAULT 0, -- Processing fees
    net_amount DECIMAL(12, 2) NOT NULL,

    -- Dates and status
    payment_date DATE NOT NULL,
    received_date DATE,
    cleared_date DATE, -- When funds are available
    status VARCHAR(20) DEFAULT 'pending', -- pending, processing, cleared, failed, refunded

    -- Payment method details
    check_number VARCHAR(50),
    bank_name VARCHAR(255),
    routing_number VARCHAR(20),
    account_number_last4 VARCHAR(4),
    transaction_id VARCHAR(255), -- Gateway transaction ID
    gateway_name VARCHAR(50), -- stripe, paypal, square, etc.

    -- References
    invoice_ids UUID[], -- Array of invoice IDs this payment covers
    external_reference VARCHAR(100),

    -- Notes and metadata
    notes TEXT,
    gateway_response JSONB, -- Full gateway response

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Payment allocations (how payments are applied to invoices)
CREATE TABLE financial.payment_allocations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    payment_id UUID NOT NULL REFERENCES financial.payments(id) ON DELETE CASCADE,
    invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE,
    allocated_amount DECIMAL(12, 2) NOT NULL,
    allocation_date DATE NOT NULL DEFAULT CURRENT_DATE,

    UNIQUE(payment_id, invoice_id)
);

-- Expenses
CREATE TABLE financial.expenses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    property_id UUID REFERENCES core.properties(id), -- NULL for organization-level expenses
    unit_id UUID REFERENCES core.units(id),
    account_id UUID NOT NULL REFERENCES financial.accounts(id),
    vendor_id UUID, -- Reference to vendors table (will be created in operations schema)

    -- Expense details
    expense_number VARCHAR(50),
    description TEXT NOT NULL,
    category VARCHAR(50), -- maintenance, utilities, insurance, taxes, etc.
    subcategory VARCHAR(50),

    -- Amounts
    amount DECIMAL(12, 2) NOT NULL,
    tax_amount DECIMAL(10, 2) DEFAULT 0,
    total_amount DECIMAL(12, 2) NOT NULL,

    -- Dates
    expense_date DATE NOT NULL,
    paid_date DATE,
    due_date DATE,

    -- Payment information
    payment_method VARCHAR(20), -- check, credit_card, bank_transfer, etc.
    check_number VARCHAR(50),
    confirmation_number VARCHAR(100),

    -- Status and approval
    status VARCHAR(20) DEFAULT 'pending', -- pending, approved, paid, rejected
    approved_by UUID REFERENCES core.user_profiles(id),
    approved_at TIMESTAMPTZ,

    -- Documentation
    receipt_urls TEXT[], -- Array of receipt/invoice URLs
    notes TEXT,

    -- Recurring expenses
    is_recurring BOOLEAN DEFAULT false,
    recurring_frequency VARCHAR(20), -- monthly, quarterly, annually
    next_due_date DATE,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Budgets
CREATE TABLE financial.budgets (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    property_id UUID REFERENCES core.properties(id), -- NULL for organization-level budget
    name VARCHAR(255) NOT NULL,
    budget_year INTEGER NOT NULL,
    budget_type VARCHAR(20) DEFAULT 'annual', -- annual, monthly, quarterly
    status VARCHAR(20) DEFAULT 'draft', -- draft, active, closed

    -- Budget totals
    total_income_budget DECIMAL(15, 2) DEFAULT 0,
    total_expense_budget DECIMAL(15, 2) DEFAULT 0,
    net_budget DECIMAL(15, 2) DEFAULT 0,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(organization_id, property_id, budget_year)
);

-- Budget line items
CREATE TABLE financial.budget_line_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    budget_id UUID NOT NULL REFERENCES financial.budgets(id) ON DELETE CASCADE,
    account_id UUID NOT NULL REFERENCES financial.accounts(id),

    -- Monthly allocations (12 months)
    jan_amount DECIMAL(12, 2) DEFAULT 0,
    feb_amount DECIMAL(12, 2) DEFAULT 0,
    mar_amount DECIMAL(12, 2) DEFAULT 0,
    apr_amount DECIMAL(12, 2) DEFAULT 0,
    may_amount DECIMAL(12, 2) DEFAULT 0,
    jun_amount DECIMAL(12, 2) DEFAULT 0,
    jul_amount DECIMAL(12, 2) DEFAULT 0,
    aug_amount DECIMAL(12, 2) DEFAULT 0,
    sep_amount DECIMAL(12, 2) DEFAULT 0,
    oct_amount DECIMAL(12, 2) DEFAULT 0,
    nov_amount DECIMAL(12, 2) DEFAULT 0,
    dec_amount DECIMAL(12, 2) DEFAULT 0,

    annual_amount DECIMAL(15, 2) GENERATED ALWAYS AS (
        jan_amount + feb_amount + mar_amount + apr_amount +
        may_amount + jun_amount + jul_amount + aug_amount +
        sep_amount + oct_amount + nov_amount + dec_amount
    ) STORED,

    notes TEXT,

    UNIQUE(budget_id, account_id)
);

-- Financial reports cache
CREATE TABLE financial.financial_reports (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organization_id UUID NOT NULL REFERENCES core.organizations(id) ON DELETE CASCADE,
    property_id UUID REFERENCES core.properties(id),
    report_type VARCHAR(50) NOT NULL, -- income_statement, balance_sheet, cash_flow, rent_roll
    report_period VARCHAR(20) NOT NULL, -- monthly, quarterly, annually, custom
    period_start DATE NOT NULL,
    period_end DATE NOT NULL,
    report_data JSONB NOT NULL, -- Cached report data
    generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    generated_by UUID REFERENCES core.user_profiles(id),

    UNIQUE(organization_id, property_id, report_type, period_start, period_end)
);

-- Create indexes for financial schema
CREATE INDEX idx_accounts_org ON financial.accounts(organization_id);
CREATE INDEX idx_accounts_type ON financial.accounts(account_type, account_category);
CREATE INDEX idx_accounts_code ON financial.accounts(organization_id, account_code);
CREATE INDEX idx_invoices_org ON financial.invoices(organization_id);
CREATE INDEX idx_invoices_tenant ON financial.invoices(tenant_id);
CREATE INDEX idx_invoices_property ON financial.invoices(property_id);
CREATE INDEX idx_invoices_status ON financial.invoices(status);
CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date) WHERE status IN ('pending', 'sent', 'partial', 'overdue');
CREATE INDEX idx_invoices_period ON financial.invoices(period_start, period_end);
CREATE INDEX idx_payments_org ON financial.payments(organization_id);
CREATE INDEX idx_payments_tenant ON financial.payments(tenant_id);
CREATE INDEX idx_payments_property ON financial.payments(property_id);
CREATE INDEX idx_payments_date ON financial.payments(payment_date);
CREATE INDEX idx_payments_status ON financial.payments(status);
CREATE INDEX idx_payment_allocations_payment ON financial.payment_allocations(payment_id);
CREATE INDEX idx_payment_allocations_invoice ON financial.payment_allocations(invoice_id);
CREATE INDEX idx_expenses_org ON financial.expenses(organization_id);
CREATE INDEX idx_expenses_property ON financial.expenses(property_id);
CREATE INDEX idx_expenses_account ON financial.expenses(account_id);
CREATE INDEX idx_expenses_date ON financial.expenses(expense_date);
CREATE INDEX idx_expenses_status ON financial.expenses(status);
CREATE INDEX idx_expenses_category ON financial.expenses(category, subcategory);
CREATE INDEX idx_budgets_org ON financial.budgets(organization_id);
CREATE INDEX idx_budgets_property_year ON financial.budgets(property_id, budget_year);
CREATE INDEX idx_financial_reports_org_type ON financial.financial_reports(organization_id, report_type);
CREATE INDEX idx_financial_reports_period ON financial.financial_reports(period_start, period_end);

Row-Level Security ImplementationΒΆ

Security Function SetupΒΆ

-- Create security functions
CREATE OR REPLACE FUNCTION current_organization_id() RETURNS UUID AS $$
BEGIN
    RETURN COALESCE(
        current_setting('app.current_organization_id', true)::UUID,
        '00000000-0000-0000-0000-000000000000'::UUID
    );
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

CREATE OR REPLACE FUNCTION current_user_id() RETURNS UUID AS $$
BEGIN
    RETURN COALESCE(
        current_setting('app.current_user_id', true)::UUID,
        '00000000-0000-0000-0000-000000000000'::UUID
    );
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

CREATE OR REPLACE FUNCTION get_user_role(user_uuid UUID, entity_uuid UUID, entity_type_param TEXT) 
RETURNS TEXT AS $$
DECLARE
    user_role TEXT;
BEGIN
    SELECT role_name INTO user_role
    FROM identity.user_roles
    WHERE user_id = user_uuid
      AND entity_id = entity_uuid
      AND entity_type = entity_type_param
      AND is_active = true
      AND (expires_at IS NULL OR expires_at > NOW());

    RETURN COALESCE(user_role, 'none');
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

CREATE OR REPLACE FUNCTION has_permission(permission_name TEXT, entity_uuid UUID DEFAULT NULL, entity_type_param TEXT DEFAULT NULL) 
RETURNS BOOLEAN AS $$
DECLARE
    user_uuid UUID := current_user_id();
    org_uuid UUID := current_organization_id();
    has_perm BOOLEAN := false;
BEGIN
    -- Check if user has the permission through their roles
    SELECT EXISTS(
        SELECT 1 FROM identity.user_roles ur
        WHERE ur.user_id = user_uuid
          AND (entity_uuid IS NULL OR ur.entity_id = entity_uuid)
          AND (entity_type_param IS NULL OR ur.entity_type = entity_type_param)
          AND ur.is_active = true
          AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
          AND (ur.permissions ? permission_name OR ur.permissions ? 'all')
    ) INTO has_perm;

    RETURN has_perm;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

RLS Policies for Core TablesΒΆ

-- Enable RLS on core tables
ALTER TABLE core.organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.properties ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.units ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.leases ENABLE ROW LEVEL SECURITY;

-- Organization access policy
CREATE POLICY organization_access ON core.organizations
FOR ALL
USING (id = current_organization_id());

-- User profiles access
CREATE POLICY user_profiles_org_access ON core.user_profiles
FOR ALL
USING (organization_id = current_organization_id());

-- Properties access - organization members can see all properties
CREATE POLICY properties_org_access ON core.properties
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        -- Admin can see all
        has_permission('properties.view_all') OR
        -- Property manager can see assigned properties
        (property_manager_id = current_user_id()) OR
        -- Staff can see properties based on role permissions
        has_permission('properties.view', id, 'property')
    )
);

-- Units access
CREATE POLICY units_access ON core.units
FOR ALL
USING (
    property_id IN (
        SELECT id FROM core.properties 
        WHERE organization_id = current_organization_id()
    ) AND
    (
        has_permission('units.view_all') OR
        has_permission('units.view', id, 'unit') OR
        has_permission('properties.view', property_id, 'property')
    )
);

-- Tenants access
CREATE POLICY tenants_access ON core.tenants
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        -- Admin/managers can see all tenants
        has_permission('tenants.view_all') OR
        -- Property managers can see tenants in their properties
        property_id IN (
            SELECT id FROM core.properties 
            WHERE property_manager_id = current_user_id()
        ) OR
        -- Tenants can see only their own data
        user_profile_id = (
            SELECT id FROM core.user_profiles 
            WHERE identity_user_id = current_user_id()
        )
    )
);

-- Leases access
CREATE POLICY leases_access ON core.leases
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        has_permission('leases.view_all') OR
        property_id IN (
            SELECT id FROM core.properties 
            WHERE property_manager_id = current_user_id()
        ) OR
        tenant_id IN (
            SELECT id FROM core.tenants 
            WHERE user_profile_id = (
                SELECT id FROM core.user_profiles 
                WHERE identity_user_id = current_user_id()
            )
        )
    )
);

RLS Policies for Financial TablesΒΆ

-- Enable RLS on financial tables
ALTER TABLE financial.invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE financial.payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE financial.expenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE financial.budgets ENABLE ROW LEVEL SECURITY;

-- Financial access policies
CREATE POLICY invoices_access ON financial.invoices
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        has_permission('finance.view_all') OR
        property_id IN (
            SELECT id FROM core.properties 
            WHERE property_manager_id = current_user_id()
        ) OR
        tenant_id IN (
            SELECT id FROM core.tenants 
            WHERE user_profile_id = (
                SELECT id FROM core.user_profiles 
                WHERE identity_user_id = current_user_id()
            )
        )
    )
);

CREATE POLICY payments_access ON financial.payments
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        has_permission('finance.view_all') OR
        property_id IN (
            SELECT id FROM core.properties 
            WHERE property_manager_id = current_user_id()
        ) OR
        tenant_id IN (
            SELECT id FROM core.tenants 
            WHERE user_profile_id = (
                SELECT id FROM core.user_profiles 
                WHERE identity_user_id = current_user_id()
            )
        )
    )
);

CREATE POLICY expenses_access ON financial.expenses
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        has_permission('finance.view_all') OR
        property_id IS NULL OR -- Organization-level expenses
        property_id IN (
            SELECT id FROM core.properties 
            WHERE property_manager_id = current_user_id()
        )
    )
);

CREATE POLICY budgets_access ON financial.budgets
FOR ALL
USING (
    organization_id = current_organization_id() AND
    (
        has_permission('finance.view_all') OR
        property_id IS NULL OR -- Organization-level budgets
        property_id IN (
            SELECT id FROM core.properties 
            WHERE property_manager_id = current_user_id()
        )
    )
);

Performance OptimizationΒΆ

Indexing StrategyΒΆ

-- Property-based query optimization (most common pattern)
CREATE INDEX CONCURRENTLY idx_units_property_status 
ON core.units(property_id, status) 
WHERE status IN ('vacant', 'occupied');

CREATE INDEX CONCURRENTLY idx_tenants_property_status 
ON core.tenants(property_id, status)
WHERE status = 'active';

CREATE INDEX CONCURRENTLY idx_leases_property_dates 
ON core.leases(property_id, start_date DESC, end_date DESC)
WHERE status = 'active';

-- Financial reporting optimization
CREATE INDEX CONCURRENTLY idx_invoices_property_period 
ON financial.invoices(property_id, period_start DESC, period_end DESC)
WHERE status IN ('pending', 'sent', 'partial', 'paid');

CREATE INDEX CONCURRENTLY idx_payments_property_date 
ON financial.payments(property_id, payment_date DESC)
WHERE status = 'cleared';

CREATE INDEX CONCURRENTLY idx_expenses_property_date_category 
ON financial.expenses(property_id, expense_date DESC, category)
WHERE status = 'paid';

-- Multi-column indexes for complex queries
CREATE INDEX CONCURRENTLY idx_transactions_comprehensive 
ON financial.invoices(organization_id, property_id, status, due_date, total_amount)
WHERE status != 'cancelled';

-- Partial indexes for specific use cases
CREATE INDEX CONCURRENTLY idx_overdue_invoices 
ON financial.invoices(due_date, organization_id)
WHERE status IN ('sent', 'partial', 'overdue') AND due_date < CURRENT_DATE;

CREATE INDEX CONCURRENTLY idx_vacant_units 
ON core.units(property_id, availability_date)
WHERE status = 'vacant';

-- Full-text search indexes
CREATE INDEX CONCURRENTLY idx_properties_search 
ON core.properties USING gin(
    to_tsvector('english', name || ' ' || COALESCE(address_line1, '') || ' ' || city)
);

CREATE INDEX CONCURRENTLY idx_tenants_search 
ON core.tenants USING gin(
    to_tsvector('english', first_name || ' ' || last_name || ' ' || COALESCE(email, ''))
);

-- JSONB indexes for metadata queries
CREATE INDEX CONCURRENTLY idx_properties_amenities 
ON core.properties USING gin(amenities);

CREATE INDEX CONCURRENTLY idx_units_features 
ON core.units USING gin(features);

-- Time-based partitioning support indexes
CREATE INDEX CONCURRENTLY idx_invoices_created_month 
ON financial.invoices(date_trunc('month', created_at), organization_id);

CREATE INDEX CONCURRENTLY idx_payments_date_month 
ON financial.payments(date_trunc('month', payment_date), organization_id);

Query Optimization ViewsΒΆ

-- Common dashboard queries optimized as materialized views
CREATE MATERIALIZED VIEW financial.property_revenue_summary AS
SELECT 
    p.id as property_id,
    p.organization_id,
    p.name as property_name,
    DATE_TRUNC('month', i.period_start) as revenue_month,
    COUNT(DISTINCT i.id) as invoice_count,
    SUM(i.total_amount) as total_invoiced,
    SUM(i.paid_amount) as total_paid,
    SUM(i.total_amount - i.paid_amount) as outstanding_balance,
    COUNT(DISTINCT CASE WHEN i.due_date < CURRENT_DATE AND i.status NOT IN ('paid', 'cancelled') THEN i.id END) as overdue_count,
    SUM(CASE WHEN i.due_date < CURRENT_DATE AND i.status NOT IN ('paid', 'cancelled') THEN i.total_amount - i.paid_amount ELSE 0 END) as overdue_amount
FROM core.properties p
LEFT JOIN financial.invoices i ON p.id = i.property_id AND i.status != 'cancelled'
WHERE i.period_start >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months')
GROUP BY p.id, p.organization_id, p.name, DATE_TRUNC('month', i.period_start);

CREATE UNIQUE INDEX idx_property_revenue_summary_unique 
ON financial.property_revenue_summary(property_id, revenue_month);

-- Occupancy summary for properties
CREATE MATERIALIZED VIEW core.property_occupancy_summary AS
SELECT 
    p.id as property_id,
    p.organization_id,
    p.name as property_name,
    COUNT(u.id) as total_units,
    COUNT(CASE WHEN u.status = 'occupied' THEN 1 END) as occupied_units,
    COUNT(CASE WHEN u.status = 'vacant' THEN 1 END) as vacant_units,
    COUNT(CASE WHEN u.status = 'maintenance' THEN 1 END) as maintenance_units,
    ROUND(
        COUNT(CASE WHEN u.status = 'occupied' THEN 1 END)::DECIMAL / 
        NULLIF(COUNT(u.id), 0) * 100, 2
    ) as occupancy_rate,
    SUM(CASE WHEN u.status = 'occupied' THEN u.current_rent ELSE 0 END) as monthly_rent_roll,
    SUM(u.market_rent) as potential_rent_roll,
    AVG(CASE WHEN u.status = 'vacant' THEN u.market_rent END) as avg_vacant_rent
FROM core.properties p
LEFT JOIN core.units u ON p.id = u.property_id
GROUP BY p.id, p.organization_id, p.name;

CREATE UNIQUE INDEX idx_property_occupancy_summary_unique 
ON core.property_occupancy_summary(property_id);

-- Maintenance request summary
CREATE MATERIALIZED VIEW operations.maintenance_summary AS
SELECT 
    p.id as property_id,
    p.organization_id,
    DATE_TRUNC('month', mr.created_at) as month,
    COUNT(*) as total_requests,
    COUNT(CASE WHEN mr.status = 'open' THEN 1 END) as open_requests,
    COUNT(CASE WHEN mr.status = 'in_progress' THEN 1 END) as in_progress_requests,
    COUNT(CASE WHEN mr.status = 'completed' THEN 1 END) as completed_requests,
    COUNT(CASE WHEN mr.priority = 'urgent' THEN 1 END) as urgent_requests,
    AVG(EXTRACT(EPOCH FROM (COALESCE(mr.completed_at, NOW()) - mr.created_at))/86400) as avg_resolution_days
FROM operations.maintenance_requests mr
JOIN core.properties p ON mr.property_id = p.id
WHERE mr.created_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months')
GROUP BY p.id, p.organization_id, DATE_TRUNC('month', mr.created_at);

-- Refresh materialized views automatically
CREATE OR REPLACE FUNCTION refresh_summary_views() RETURNS VOID AS $$
BEGIN
    REFRESH MATERIALIZED VIEW CONCURRENTLY financial.property_revenue_summary;
    REFRESH MATERIALIZED VIEW CONCURRENTLY core.property_occupancy_summary;
    REFRESH MATERIALIZED VIEW CONCURRENTLY operations.maintenance_summary;
END;
$$ LANGUAGE plpgsql;

-- Schedule refresh every hour
SELECT cron.schedule('refresh-summary-views', '0 * * * *', 'SELECT refresh_summary_views();');

Connection Pool ConfigurationΒΆ

-- PgBouncer configuration for multi-tenant setup
-- /etc/pgbouncer/pgbouncer.ini

[databases]
property_management = host=localhost port=5432 dbname=property_management pool_size=25 max_db_connections=100

[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
admin_users = admin
stats_users = stats
pool_mode = transaction
server_reset_query = DISCARD ALL
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
max_db_connections = 100
max_user_connections = 50
server_lifetime = 3600
server_idle_timeout = 600
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1
stats_period = 60

Caching StrategyΒΆ

-- Redis caching implementation for frequently accessed data
-- User session and profile caching
-- Key pattern: user_profile:{user_id}
-- TTL: 30 minutes

-- Organization settings caching  
-- Key pattern: org_settings:{organization_id}
-- TTL: 1 hour

-- Property summary caching
-- Key pattern: property_summary:{property_id}
-- TTL: 15 minutes

-- Financial dashboard caching
-- Key pattern: financial_dashboard:{organization_id}:{month}
-- TTL: 5 minutes

-- Example cache warming function
CREATE OR REPLACE FUNCTION warm_property_cache(prop_id UUID) RETURNS JSONB AS $$
DECLARE
    cache_data JSONB;
BEGIN
    SELECT jsonb_build_object(
        'property_id', p.id,
        'name', p.name,
        'total_units', COUNT(u.id),
        'occupied_units', COUNT(CASE WHEN u.status = 'occupied' THEN 1 END),
        'vacant_units', COUNT(CASE WHEN u.status = 'vacant' THEN 1 END),
        'monthly_rent_roll', SUM(CASE WHEN u.status = 'occupied' THEN u.current_rent ELSE 0 END),
        'occupancy_rate', ROUND(
            COUNT(CASE WHEN u.status = 'occupied' THEN 1 END)::DECIMAL / 
            NULLIF(COUNT(u.id), 0) * 100, 2
        ),
        'last_updated', NOW()
    )
    INTO cache_data
    FROM core.properties p
    LEFT JOIN core.units u ON p.id = u.property_id
    WHERE p.id = prop_id
    GROUP BY p.id, p.name;

    RETURN cache_data;
END;
$$ LANGUAGE plpgsql;

Data Integrity & ConstraintsΒΆ

Foreign Key RelationshipsΒΆ

-- Cross-schema foreign key constraints
ALTER TABLE core.user_profiles 
ADD CONSTRAINT fk_user_profiles_identity 
FOREIGN KEY (identity_user_id) REFERENCES identity.user_profiles(id);

ALTER TABLE financial.invoices 
ADD CONSTRAINT fk_invoices_tenant 
FOREIGN KEY (tenant_id) REFERENCES core.tenants(id);

ALTER TABLE financial.invoices 
ADD CONSTRAINT fk_invoices_property 
FOREIGN KEY (property_id) REFERENCES core.properties(id);

ALTER TABLE financial.payments 
ADD CONSTRAINT fk_payments_tenant 
FOREIGN KEY (tenant_id) REFERENCES core.tenants(id);

-- Ensure data consistency with check constraints
ALTER TABLE core.units 
ADD CONSTRAINT check_unit_rent_positive 
CHECK (market_rent >= 0 AND current_rent >= 0);

ALTER TABLE core.units 
ADD CONSTRAINT check_unit_rooms_valid 
CHECK (bedrooms >= 0 AND bathrooms >= 0);

ALTER TABLE financial.invoices 
ADD CONSTRAINT check_invoice_amounts_valid 
CHECK (subtotal >= 0 AND total_amount >= 0 AND paid_amount >= 0 AND balance_due >= 0);

ALTER TABLE financial.invoices 
ADD CONSTRAINT check_invoice_balance_correct 
CHECK (balance_due = total_amount - paid_amount);

ALTER TABLE financial.payments 
ADD CONSTRAINT check_payment_amount_positive 
CHECK (payment_amount > 0 AND net_amount > 0);

Data Validation TriggersΒΆ

-- Trigger to automatically update unit status based on lease
CREATE OR REPLACE FUNCTION update_unit_status_on_lease_change() 
RETURNS TRIGGER AS $$
BEGIN
    -- When lease becomes active, mark unit as occupied
    IF NEW.status = 'active' AND (OLD.status IS NULL OR OLD.status != 'active') THEN
        UPDATE core.units 
        SET status = 'occupied', 
            lease_start_date = NEW.start_date,
            lease_end_date = NEW.end_date
        WHERE id = NEW.unit_id;
    END IF;

    -- When lease ends or is terminated, mark unit as vacant
    IF NEW.status IN ('expired', 'terminated') AND OLD.status = 'active' THEN
        UPDATE core.units 
        SET status = 'vacant', 
            availability_date = CURRENT_DATE + INTERVAL '30 days',
            lease_start_date = NULL,
            lease_end_date = NULL
        WHERE id = NEW.unit_id;
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_update_unit_status_on_lease_change
AFTER UPDATE ON core.leases
FOR EACH ROW EXECUTE FUNCTION update_unit_status_on_lease_change();

-- Trigger to auto-update invoice balance
CREATE OR REPLACE FUNCTION update_invoice_balance() 
RETURNS TRIGGER AS $$
BEGIN
    -- Update balance when payments are allocated
    IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
        UPDATE financial.invoices 
        SET paid_amount = COALESCE((
            SELECT SUM(allocated_amount) 
            FROM financial.payment_allocations 
            WHERE invoice_id = NEW.invoice_id
        ), 0),
        balance_due = total_amount - COALESCE((
            SELECT SUM(allocated_amount) 
            FROM financial.payment_allocations 
            WHERE invoice_id = NEW.invoice_id
        ), 0),
        status = CASE 
            WHEN total_amount - COALESCE((
                SELECT SUM(allocated_amount) 
                FROM financial.payment_allocations 
                WHERE invoice_id = NEW.invoice_id
            ), 0) <= 0 THEN 'paid'
            WHEN COALESCE((
                SELECT SUM(allocated_amount) 
                FROM financial.payment_allocations 
                WHERE invoice_id = NEW.invoice_id
            ), 0) > 0 THEN 'partial'
            ELSE status
        END
        WHERE id = NEW.invoice_id;

        RETURN NEW;
    END IF;

    IF TG_OP = 'DELETE' THEN
        UPDATE financial.invoices 
        SET paid_amount = COALESCE((
            SELECT SUM(allocated_amount) 
            FROM financial.payment_allocations 
            WHERE invoice_id = OLD.invoice_id
        ), 0),
        balance_due = total_amount - COALESCE((
            SELECT SUM(allocated_amount) 
            FROM financial.payment_allocations 
            WHERE invoice_id = OLD.invoice_id
        ), 0),
        status = CASE 
            WHEN total_amount - COALESCE((
                SELECT SUM(allocated_amount) 
                FROM financial.payment_allocations 
                WHERE invoice_id = OLD.invoice_id
            ), 0) <= 0 THEN 'paid'
            WHEN COALESCE((
                SELECT SUM(allocated_amount) 
                FROM financial.payment_allocations 
                WHERE invoice_id = OLD.invoice_id
            ), 0) > 0 THEN 'partial'
            ELSE 'pending'
        END
        WHERE id = OLD.invoice_id;

        RETURN OLD;
    END IF;

    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_update_invoice_balance
AFTER INSERT OR UPDATE OR DELETE ON financial.payment_allocations
FOR EACH ROW EXECUTE FUNCTION update_invoice_balance();

-- Audit trail trigger for all tables
CREATE OR REPLACE FUNCTION create_audit_trail() 
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO shared.audit_logs(
        table_name, 
        record_id, 
        action, 
        old_values, 
        new_values, 
        user_id, 
        organization_id,
        ip_address,
        user_agent
    ) VALUES (
        TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
        CASE 
            WHEN TG_OP = 'DELETE' THEN OLD.id 
            ELSE NEW.id 
        END,
        TG_OP,
        CASE 
            WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) 
            ELSE NULL 
        END,
        CASE 
            WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) 
            ELSE NULL 
        END,
        current_user_id(),
        current_organization_id(),
        inet_client_addr(),
        current_setting('application_name', true)
    );

    RETURN CASE 
        WHEN TG_OP = 'DELETE' THEN OLD 
        ELSE NEW 
    END;
END;
$$ LANGUAGE plpgsql;

-- Apply audit trigger to all major tables
DO $$
DECLARE
    t text;
BEGIN
    FOR t IN 
        SELECT schemaname||'.'||tablename 
        FROM pg_tables 
        WHERE schemaname IN ('core', 'financial', 'operations', 'communication') 
        AND tablename NOT LIKE '%_audit%'
    LOOP
        EXECUTE format('CREATE TRIGGER audit_%s 
            AFTER INSERT OR UPDATE OR DELETE ON %s 
            FOR EACH ROW EXECUTE FUNCTION create_audit_trail()', 
            replace(t, '.', '_'), t);
    END LOOP;
END
$$;

Migration & DeploymentΒΆ

Schema Migration FrameworkΒΆ

-- Migration tracking table
CREATE SCHEMA IF NOT EXISTS migrations;

CREATE TABLE migrations.schema_versions (
    id SERIAL PRIMARY KEY,
    version VARCHAR(20) NOT NULL UNIQUE,
    description TEXT,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    applied_by VARCHAR(255) NOT NULL DEFAULT current_user,
    execution_time_ms INTEGER,
    checksum VARCHAR(64)
);

-- Migration execution function
CREATE OR REPLACE FUNCTION migrations.execute_migration(
    version_number VARCHAR(20),
    description_text TEXT,
    migration_sql TEXT
) RETURNS VOID AS $$
DECLARE
    start_time TIMESTAMPTZ;
    end_time TIMESTAMPTZ;
    execution_ms INTEGER;
    sql_checksum VARCHAR(64);
BEGIN
    -- Check if migration already applied
    IF EXISTS (SELECT 1 FROM migrations.schema_versions WHERE version = version_number) THEN
        RAISE NOTICE 'Migration % already applied, skipping', version_number;
        RETURN;
    END IF;

    start_time := clock_timestamp();
    sql_checksum := encode(digest(migration_sql, 'sha256'), 'hex');

    -- Execute migration in transaction
    BEGIN
        EXECUTE migration_sql;

        end_time := clock_timestamp();
        execution_ms := extract(milliseconds from end_time - start_time)::INTEGER;

        INSERT INTO migrations.schema_versions (version, description, execution_time_ms, checksum)
        VALUES (version_number, description_text, execution_ms, sql_checksum);

        RAISE NOTICE 'Migration % completed successfully in % ms', version_number, execution_ms;

    EXCEPTION WHEN OTHERS THEN
        RAISE EXCEPTION 'Migration % failed: %', version_number, SQLERRM;
    END;
END;
$$ LANGUAGE plpgsql;

Database Setup ScriptsΒΆ

#!/bin/bash
# scripts/setup-database.sh

set -e

DB_NAME=${DB_NAME:-property_management}
DB_USER=${DB_USER:-pm_admin}
DB_PASSWORD=${DB_PASSWORD:-secure_password}
DB_HOST=${DB_HOST:-localhost}
DB_PORT=${DB_PORT:-5432}

echo "Setting up Property Management System database..."

# Create database and user
psql -h $DB_HOST -p $DB_PORT -U postgres <<EOF
CREATE DATABASE $DB_NAME;
CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
ALTER USER $DB_USER CREATEDB; -- For testing databases
EOF

# Connect to the new database and set up extensions
psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME <<EOF
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- For fuzzy text search
CREATE EXTENSION IF NOT EXISTS "postgis"; -- For geospatial data
CREATE EXTENSION IF NOT EXISTS "pg_cron"; -- For scheduled tasks

-- Set up timezone
SET timezone = 'UTC';

-- Create schemas
CREATE SCHEMA IF NOT EXISTS identity;
CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS financial;
CREATE SCHEMA IF NOT EXISTS operations;
CREATE SCHEMA IF NOT EXISTS communication;
CREATE SCHEMA IF NOT EXISTS shared;
CREATE SCHEMA IF NOT EXISTS migrations;

-- Grant schema permissions
GRANT USAGE ON SCHEMA identity TO $DB_USER;
GRANT USAGE ON SCHEMA core TO $DB_USER;
GRANT USAGE ON SCHEMA financial TO $DB_USER;
GRANT USAGE ON SCHEMA operations TO $DB_USER;
GRANT USAGE ON SCHEMA communication TO $DB_USER;
GRANT USAGE ON SCHEMA shared TO $DB_USER;

-- Set search path
ALTER DATABASE $DB_NAME SET search_path = core,financial,operations,communication,shared,identity,public;
EOF

echo "Database setup completed successfully!"
echo "Database: $DB_NAME"
echo "Host: $DB_HOST:$DB_PORT"
echo "User: $DB_USER"

Environment-Specific ConfigurationsΒΆ

# docker-compose.database.yml
version: '3.8'

services:
  postgres:
    image: postgis/postgis:15-3.3
    environment:
      POSTGRES_DB: property_management
      POSTGRES_USER: pm_admin
      POSTGRES_PASSWORD: secure_password
      POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --locale=en_US.UTF-8"
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql
      - ./migrations:/docker-entrypoint-initdb.d/migrations
    command: >
      postgres 
      -c shared_buffers=256MB 
      -c effective_cache_size=1GB 
      -c maintenance_work_mem=64MB 
      -c checkpoint_completion_target=0.9 
      -c wal_buffers=16MB 
      -c default_statistics_target=100 
      -c random_page_cost=1.1 
      -c effective_io_concurrency=200
      -c max_connections=200
      -c log_statement=all
      -c log_duration=on
      -c log_min_duration_statement=100

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru

  pgbouncer:
    image: pgbouncer/pgbouncer:latest
    environment:
      DATABASES_HOST: postgres
      DATABASES_PORT: 5432
      DATABASES_USER: pm_admin
      DATABASES_PASSWORD: secure_password
      DATABASES_DBNAME: property_management
      POOL_MODE: transaction
      MAX_CLIENT_CONN: 1000
      DEFAULT_POOL_SIZE: 25
    ports:
      - "6432:6432"
    depends_on:
      - postgres

volumes:
  postgres_data:
  redis_data:

Backup & RecoveryΒΆ

Backup Strategy ImplementationΒΆ

#!/bin/bash
# scripts/backup-database.sh

set -e

# Configuration
DB_NAME="property_management"
DB_USER="pm_admin"
DB_HOST="localhost"
DB_PORT="5432"
BACKUP_DIR="/backups/property_management"
S3_BUCKET="pm-backups"
RETENTION_DAYS="30"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")

echo "Starting database backup process..."

# Create backup directory
mkdir -p $BACKUP_DIR/daily
mkdir -p $BACKUP_DIR/weekly
mkdir -p $BACKUP_DIR/monthly

# Determine backup type
DAY_OF_WEEK=$(date +%u)
DAY_OF_MONTH=$(date +%d)

if [ "$DAY_OF_MONTH" = "01" ]; then
    BACKUP_TYPE="monthly"
    BACKUP_SUBDIR="monthly"
elif [ "$DAY_OF_WEEK" = "7" ]; then
    BACKUP_TYPE="weekly"
    BACKUP_SUBDIR="weekly"
else
    BACKUP_TYPE="daily"
    BACKUP_SUBDIR="daily"
fi

BACKUP_FILE="$BACKUP_DIR/$BACKUP_SUBDIR/pm_${BACKUP_TYPE}_${TIMESTAMP}.sql.gz"

echo "Creating $BACKUP_TYPE backup: $BACKUP_FILE"

# Create compressed backup
pg_dump -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME \
    --verbose \
    --format=custom \
    --no-owner \
    --no-privileges \
    --compress=9 \
    --file=$BACKUP_FILE

# Verify backup
if [ $? -eq 0 ] && [ -s $BACKUP_FILE ]; then
    echo "Backup created successfully: $BACKUP_FILE"
    BACKUP_SIZE=$(du -h $BACKUP_FILE | cut -f1)
    echo "Backup size: $BACKUP_SIZE"
else
    echo "Backup failed!"
    exit 1
fi

# Upload to S3
aws s3 cp $BACKUP_FILE s3://$S3_BUCKET/database/$BACKUP_TYPE/ --storage-class STANDARD_IA

# Schema-only backup for disaster recovery
SCHEMA_BACKUP_FILE="$BACKUP_DIR/schema_${TIMESTAMP}.sql"
pg_dump -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME \
    --schema-only \
    --verbose \
    --file=$SCHEMA_BACKUP_FILE

gzip $SCHEMA_BACKUP_FILE
aws s3 cp ${SCHEMA_BACKUP_FILE}.gz s3://$S3_BUCKET/schema/

# WAL archiving setup (continuous backup)
# This should be configured in postgresql.conf:
# wal_level = replica
# archive_mode = on
# archive_command = 'aws s3 cp %p s3://pm-backups/wal/%f'

# Cleanup old backups
find $BACKUP_DIR/daily -name "*.sql.gz" -mtime +7 -delete
find $BACKUP_DIR/weekly -name "*.sql.gz" -mtime +30 -delete
find $BACKUP_DIR/monthly -name "*.sql.gz" -mtime +365 -delete

echo "Backup process completed successfully!"

Point-in-Time RecoveryΒΆ

#!/bin/bash
# scripts/restore-database.sh

set -e

RESTORE_TIME=${1:-"latest"}
DB_NAME="property_management"
RESTORE_DB_NAME="${DB_NAME}_restore_$(date +%Y%m%d_%H%M%S)"
S3_BUCKET="pm-backups"

echo "Starting point-in-time recovery..."
echo "Restore time: $RESTORE_TIME"
echo "Target database: $RESTORE_DB_NAME"

# Download latest base backup
LATEST_BACKUP=$(aws s3 ls s3://$S3_BUCKET/database/daily/ --recursive | sort | tail -n 1 | awk '{print $4}')
echo "Using base backup: $LATEST_BACKUP"

aws s3 cp s3://$S3_BUCKET/$LATEST_BACKUP /tmp/base_backup.sql.gz

# Create restore database
createdb $RESTORE_DB_NAME

# Restore base backup
echo "Restoring base backup..."
gunzip -c /tmp/base_backup.sql.gz | psql -d $RESTORE_DB_NAME

# If specific time requested, replay WAL files
if [ "$RESTORE_TIME" != "latest" ]; then
    echo "Replaying WAL files to $RESTORE_TIME..."

    # Download and apply WAL files
    # This requires setting up recovery.conf with:
    # restore_command = 'aws s3 cp s3://pm-backups/wal/%f %p'
    # recovery_target_time = '$RESTORE_TIME'

    # Create recovery configuration
    cat > /tmp/recovery.conf <<EOF
restore_command = 'aws s3 cp s3://$S3_BUCKET/wal/%f %p'
recovery_target_time = '$RESTORE_TIME'
recovery_target_action = 'promote'
EOF

    # Stop database, copy recovery.conf, start database
    sudo systemctl stop postgresql
    sudo cp /tmp/recovery.conf /var/lib/postgresql/data/
    sudo systemctl start postgresql

    echo "Waiting for recovery to complete..."
    while [ ! -f /var/lib/postgresql/data/recovery.done ]; do
        sleep 5
        echo "Recovery in progress..."
    done
fi

echo "Database restored successfully as: $RESTORE_DB_NAME"
echo "Validate the restored data and rename if needed:"
echo "  ALTER DATABASE $RESTORE_DB_NAME RENAME TO $DB_NAME;"

# Cleanup
rm -f /tmp/base_backup.sql.gz /tmp/recovery.conf

Monitoring & MaintenanceΒΆ

Performance Monitoring SetupΒΆ

-- Create monitoring schema and tables
CREATE SCHEMA IF NOT EXISTS monitoring;

-- Query performance tracking
CREATE TABLE monitoring.slow_queries (
    id SERIAL PRIMARY KEY,
    query_hash VARCHAR(64) NOT NULL,
    query_text TEXT NOT NULL,
    mean_time DECIMAL(10,3) NOT NULL,
    calls INTEGER NOT NULL,
    total_time DECIMAL(12,3) NOT NULL,
    min_time DECIMAL(10,3) NOT NULL,
    max_time DECIMAL(10,3) NOT NULL,
    stddev_time DECIMAL(10,3) NOT NULL,
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index usage tracking
CREATE TABLE monitoring.index_usage (
    id SERIAL PRIMARY KEY,
    schema_name VARCHAR(64) NOT NULL,
    table_name VARCHAR(64) NOT NULL,
    index_name VARCHAR(64) NOT NULL,
    idx_scan BIGINT NOT NULL,
    idx_tup_read BIGINT NOT NULL,
    idx_tup_fetch BIGINT NOT NULL,
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Connection tracking
CREATE TABLE monitoring.connection_stats (
    id SERIAL PRIMARY KEY,
    database_name VARCHAR(64) NOT NULL,
    username VARCHAR(64) NOT NULL,
    application_name VARCHAR(64),
    state VARCHAR(20) NOT NULL,
    connection_count INTEGER NOT NULL,
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Monitoring functions
CREATE OR REPLACE FUNCTION monitoring.record_slow_queries() RETURNS VOID AS $$
BEGIN
    INSERT INTO monitoring.slow_queries (query_hash, query_text, mean_time, calls, total_time, min_time, max_time, stddev_time)
    SELECT 
        encode(digest(query, 'sha256'), 'hex') as query_hash,
        query,
        mean_time,
        calls,
        total_time,
        min_time,
        max_time,
        stddev_time
    FROM pg_stat_statements 
    WHERE mean_time > 100 -- Only queries taking >100ms on average
    ON CONFLICT (query_hash) DO UPDATE SET
        mean_time = EXCLUDED.mean_time,
        calls = EXCLUDED.calls,
        total_time = EXCLUDED.total_time,
        min_time = EXCLUDED.min_time,
        max_time = EXCLUDED.max_time,
        stddev_time = EXCLUDED.stddev_time,
        recorded_at = NOW();
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION monitoring.record_index_usage() RETURNS VOID AS $$
BEGIN
    INSERT INTO monitoring.index_usage (schema_name, table_name, index_name, idx_scan, idx_tup_read, idx_tup_fetch)
    SELECT 
        schemaname,
        tablename,
        indexname,
        idx_scan,
        idx_tup_read,
        idx_tup_fetch
    FROM pg_stat_user_indexes
    ON CONFLICT (schema_name, table_name, index_name) DO UPDATE SET
        idx_scan = EXCLUDED.idx_scan,
        idx_tup_read = EXCLUDED.idx_tup_read,
        idx_tup_fetch = EXCLUDED.idx_tup_fetch,
        recorded_at = NOW();
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION monitoring.record_connection_stats() RETURNS VOID AS $$
BEGIN
    INSERT INTO monitoring.connection_stats (database_name, username, application_name, state, connection_count)
    SELECT 
        datname,
        usename,
        application_name,
        state,
        count(*)
    FROM pg_stat_activity
    WHERE state IS NOT NULL
    GROUP BY datname, usename, application_name, state;
END;
$$ LANGUAGE plpgsql;

-- Schedule monitoring tasks
SELECT cron.schedule('record-slow-queries', '*/5 * * * *', 'SELECT monitoring.record_slow_queries();');
SELECT cron.schedule('record-index-usage', '0 */6 * * *', 'SELECT monitoring.record_index_usage();');
SELECT cron.schedule('record-connection-stats', '* * * * *', 'SELECT monitoring.record_connection_stats();');

Maintenance TasksΒΆ

-- Automated maintenance procedures
CREATE OR REPLACE FUNCTION maintenance.analyze_all_tables() RETURNS VOID AS $$
DECLARE
    table_rec RECORD;
BEGIN
    FOR table_rec IN 
        SELECT schemaname, tablename 
        FROM pg_tables 
        WHERE schemaname IN ('core', 'financial', 'operations', 'communication')
    LOOP
        EXECUTE format('ANALYZE %I.%I', table_rec.schemaname, table_rec.tablename);
        RAISE NOTICE 'Analyzed table: %.%', table_rec.schemaname, table_rec.tablename;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION maintenance.vacuum_tables() RETURNS VOID AS $$
DECLARE
    table_rec RECORD;
BEGIN
    FOR table_rec IN 
        SELECT schemaname, tablename, n_dead_tup, n_tup_upd + n_tup_del as total_changes
        FROM pg_stat_user_tables 
        WHERE schemaname IN ('core', 'financial', 'operations', 'communication')
        AND (n_dead_tup > 1000 OR n_tup_upd + n_tup_del > 10000)
        ORDER BY n_dead_tup DESC
    LOOP
        EXECUTE format('VACUUM ANALYZE %I.%I', table_rec.schemaname, table_rec.tablename);
        RAISE NOTICE 'Vacuumed table: %.% (% dead tuples, % total changes)', 
                     table_rec.schemaname, table_rec.tablename, 
                     table_rec.n_dead_tup, table_rec.total_changes;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION maintenance.reindex_fragmented_indexes() RETURNS VOID AS $$
DECLARE
    index_rec RECORD;
BEGIN
    -- Find fragmented indexes (this is a simplified check)
    FOR index_rec IN 
        SELECT schemaname, tablename, indexname
        FROM pg_stat_user_indexes 
        WHERE schemaname IN ('core', 'financial', 'operations', 'communication')
        AND idx_scan > 0 -- Only reindex used indexes
        ORDER BY idx_tup_read DESC
        LIMIT 20 -- Limit to top 20 most accessed indexes
    LOOP
        EXECUTE format('REINDEX INDEX CONCURRENTLY %I.%I', index_rec.schemaname, index_rec.indexname);
        RAISE NOTICE 'Reindexed: %.%', index_rec.schemaname, index_rec.indexname;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

-- Schedule maintenance tasks
SELECT cron.schedule('analyze-tables', '0 2 * * *', 'SELECT maintenance.analyze_all_tables();');
SELECT cron.schedule('vacuum-tables', '0 3 * * 0', 'SELECT maintenance.vacuum_tables();');
SELECT cron.schedule('reindex-fragmented', '0 4 * * 0', 'SELECT maintenance.reindex_fragmented_indexes();');

-- Clean up old audit logs (keep 1 year)
SELECT cron.schedule('cleanup-audit-logs', '0 5 1 * *', 
    'DELETE FROM shared.audit_logs WHERE created_at < NOW() - INTERVAL ''1 year'';');

-- Refresh materialized views
SELECT cron.schedule('refresh-materialized-views', '0 1 * * *', 
    'SELECT refresh_summary_views();');

Health Check QueriesΒΆ

-- Database health check functions
CREATE OR REPLACE FUNCTION monitoring.database_health_check() 
RETURNS TABLE(check_name TEXT, status TEXT, value TEXT, recommendation TEXT) AS $$
BEGIN
    -- Connection count check
    RETURN QUERY
    SELECT 
        'Active Connections'::TEXT,
        CASE WHEN count(*) < 100 THEN 'OK' ELSE 'WARNING' END::TEXT,
        count(*)::TEXT,
        CASE WHEN count(*) >= 100 THEN 'Consider connection pooling' ELSE 'Normal' END::TEXT
    FROM pg_stat_activity WHERE state = 'active';

    -- Long running queries
    RETURN QUERY
    SELECT 
        'Long Running Queries'::TEXT,
        CASE WHEN count(*) = 0 THEN 'OK' ELSE 'WARNING' END::TEXT,
        count(*)::TEXT,
        CASE WHEN count(*) > 0 THEN 'Review queries running > 30 minutes' ELSE 'Normal' END::TEXT
    FROM pg_stat_activity 
    WHERE state = 'active' AND now() - query_start > interval '30 minutes';

    -- Unused indexes
    RETURN QUERY
    SELECT 
        'Unused Indexes'::TEXT,
        CASE WHEN count(*) < 10 THEN 'OK' ELSE 'WARNING' END::TEXT,
        count(*)::TEXT,
        CASE WHEN count(*) >= 10 THEN 'Consider dropping unused indexes' ELSE 'Normal' END::TEXT
    FROM pg_stat_user_indexes 
    WHERE idx_scan = 0 AND schemaname IN ('core', 'financial', 'operations', 'communication');

    -- Cache hit ratio
    RETURN QUERY
    SELECT 
        'Cache Hit Ratio'::TEXT,
        CASE WHEN round(sum(blks_hit)*100.0/sum(blks_hit+blks_read), 2) > 95 THEN 'OK' ELSE 'WARNING' END::TEXT,
        round(sum(blks_hit)*100.0/sum(blks_hit+blks_read), 2)::TEXT || '%',
        CASE WHEN round(sum(blks_hit)*100.0/sum(blks_hit+blks_read), 2) <= 95 THEN 'Consider increasing shared_buffers' ELSE 'Good' END::TEXT
    FROM pg_stat_database WHERE datname = current_database();

    -- Dead tuple ratio
    RETURN QUERY
    SELECT 
        'Dead Tuples'::TEXT,
        CASE WHEN COALESCE(sum(n_dead_tup), 0) < sum(n_live_tup) * 0.1 THEN 'OK' ELSE 'WARNING' END::TEXT,
        COALESCE(sum(n_dead_tup), 0)::TEXT,
        CASE WHEN COALESCE(sum(n_dead_tup), 0) >= sum(n_live_tup) * 0.1 THEN 'Run VACUUM on affected tables' ELSE 'Normal' END::TEXT
    FROM pg_stat_user_tables 
    WHERE schemaname IN ('core', 'financial', 'operations', 'communication');

    -- Replication lag (if applicable)
    IF EXISTS (SELECT 1 FROM pg_stat_replication) THEN
        RETURN QUERY
        SELECT 
            'Replication Lag'::TEXT,
            CASE WHEN max(replay_lag) < interval '1 minute' THEN 'OK' ELSE 'WARNING' END::TEXT,
            max(replay_lag)::TEXT,
            CASE WHEN max(replay_lag) >= interval '1 minute' THEN 'Check replication health' ELSE 'Good' END::TEXT
        FROM pg_stat_replication;
    END IF;
END;
$$ LANGUAGE plpgsql;

-- Query to run health check
-- SELECT * FROM monitoring.database_health_check();

Development GuidelinesΒΆ

Database Development Best PracticesΒΆ

-- Development helper functions
CREATE OR REPLACE FUNCTION dev.create_test_organization(org_name TEXT DEFAULT 'Test Organization')
RETURNS UUID AS $$
DECLARE
    org_id UUID;
BEGIN
    INSERT INTO core.organizations (name, slug, subscription_plan, max_properties, max_users)
    VALUES (org_name, lower(replace(org_name, ' ', '_')), 'enterprise', 100, 500)
    RETURNING id INTO org_id;

    -- Create default accounts for the organization
    INSERT INTO financial.accounts (organization_id, account_code, account_name, account_type, account_category, is_system_account) VALUES
    (org_id, '1000', 'Cash', 'asset', 'current_assets', true),
    (org_id, '1100', 'Accounts Receivable', 'asset', 'current_assets', true),
    (org_id, '1200', 'Security Deposits Held', 'liability', 'current_liabilities', true),
    (org_id, '4000', 'Rental Income', 'income', 'operating_income', true),
    (org_id, '5000', 'Property Maintenance', 'expense', 'operating_expenses', true),
    (org_id, '5100', 'Utilities', 'expense', 'operating_expenses', true),
    (org_id, '5200', 'Property Management Fees', 'expense', 'operating_expenses', true),
    (org_id, '5300', 'Insurance', 'expense', 'operating_expenses', true),
    (org_id, '5400', 'Property Taxes', 'expense', 'operating_expenses', true);

    RETURN org_id;
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION dev.create_test_property(
    org_id UUID, 
    property_name TEXT DEFAULT 'Test Property',
    unit_count INTEGER DEFAULT 10
) RETURNS UUID AS $$
DECLARE
    prop_id UUID;
    i INTEGER;
BEGIN
    -- Create property
    INSERT INTO core.properties (
        organization_id, name, property_type, subtype, 
        address_line1, city, state_province, country, postal_code,
        total_units, year_built, total_area_sqft
    ) VALUES (
        org_id, property_name, 'residential', 'apartment',
        '123 Main Street', 'Test City', 'Test State', 'US', '12345',
        unit_count, 2020, 50000
    ) RETURNING id INTO prop_id;

    -- Create units
    FOR i IN 1..unit_count LOOP
        INSERT INTO core.units (
            property_id, unit_number, unit_type, square_feet,
            bedrooms, bathrooms, market_rent, current_rent
        ) VALUES (
            prop_id, 
            CASE 
                WHEN i < 10 THEN '10' || i::TEXT
                ELSE '1' || i::TEXT
            END,
            CASE 
                WHEN i % 3 = 0 THEN '2br'
                WHEN i % 2 = 0 THEN '1br'
                ELSE 'studio'
            END,
            CASE 
                WHEN i % 3 = 0 THEN 1200
                WHEN i % 2 = 0 THEN 900
                ELSE 600
            END,
            CASE 
                WHEN i % 3 = 0 THEN 2
                WHEN i % 2 = 0 THEN 1
                ELSE 0
            END,
            CASE 
                WHEN i % 3 = 0 THEN 2
                WHEN i % 2 = 0 THEN 1
                ELSE 1
            END,
            CASE 
                WHEN i % 3 = 0 THEN 2400
                WHEN i % 2 = 0 THEN 1800
                ELSE 1200
            END,
            CASE 
                WHEN i % 3 = 0 THEN 2400
                WHEN i % 2 = 0 THEN 1800
                ELSE 1200
            END
        );
    END LOOP;

    RETURN prop_id;
END;
$$ LANGUAGE plpgsql;

-- Seed data for development
CREATE OR REPLACE FUNCTION dev.seed_development_data() RETURNS VOID AS $$
DECLARE
    test_org_id UUID;
    test_prop_id UUID;
BEGIN
    -- Create test organization
    SELECT dev.create_test_organization('Development Properties Inc.') INTO test_org_id;

    -- Create test property
    SELECT dev.create_test_property(test_org_id, 'Sunrise Apartments', 20) INTO test_prop_id;

    -- Set application context for RLS
    PERFORM set_config('app.current_organization_id', test_org_id::TEXT, true);

    RAISE NOTICE 'Development data seeded successfully!';
    RAISE NOTICE 'Organization ID: %', test_org_id;
    RAISE NOTICE 'Property ID: %', test_prop_id;
END;
$$ LANGUAGE plpgsql;

Database Testing FrameworkΒΆ

-- Testing schema and functions
CREATE SCHEMA IF NOT EXISTS testing;

-- Test result tracking
CREATE TABLE testing.test_results (
    id SERIAL PRIMARY KEY,
    test_name VARCHAR(255) NOT NULL,
    test_category VARCHAR(100),
    status VARCHAR(20) NOT NULL, -- pass, fail, error
    message TEXT,
    execution_time_ms INTEGER,
    run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Test runner function
CREATE OR REPLACE FUNCTION testing.run_test(
    test_name_param VARCHAR(255),
    test_sql TEXT,
    expected_result TEXT DEFAULT NULL,
    test_category_param VARCHAR(100) DEFAULT 'general'
) RETURNS BOOLEAN AS $$
DECLARE
    start_time TIMESTAMPTZ;
    end_time TIMESTAMPTZ;
    execution_ms INTEGER;
    actual_result TEXT;
    test_status VARCHAR(20);
    test_message TEXT;
BEGIN
    start_time := clock_timestamp();

    BEGIN
        EXECUTE test_sql INTO actual_result;

        IF expected_result IS NOT NULL THEN
            IF actual_result = expected_result THEN
                test_status := 'pass';
                test_message := 'Test passed';
            ELSE
                test_status := 'fail';
                test_message := format('Expected: %s, Actual: %s', expected_result, actual_result);
            END IF;
        ELSE
            test_status := 'pass';
            test_message := 'Test completed without error';
        END IF;

    EXCEPTION WHEN OTHERS THEN
        test_status := 'error';
        test_message := SQLERRM;
    END;

    end_time := clock_timestamp();
    execution_ms := extract(milliseconds from end_time - start_time)::INTEGER;

    INSERT INTO testing.test_results (test_name, test_category, status, message, execution_time_ms)
    VALUES (test_name_param, test_category_param, test_status, test_message, execution_ms);

    RETURN test_status = 'pass';
END;
$$ LANGUAGE plpgsql;

-- Sample tests
CREATE OR REPLACE FUNCTION testing.run_all_tests() RETURNS TABLE(test_name TEXT, status TEXT, message TEXT) AS $$
BEGIN
    -- Test organization creation
    PERFORM testing.run_test(
        'organization_creation',
        'SELECT count(*)::TEXT FROM core.organizations WHERE name = ''Test Org''',
        NULL,
        'core_functionality'
    );

    -- Test RLS policies
    PERFORM testing.run_test(
        'rls_organization_isolation',
        $test$
        SET row_security = on;
        SELECT set_config('app.current_organization_id', '00000000-0000-0000-0000-000000000001', true);
        SELECT count(*)::TEXT FROM core.properties WHERE organization_id != '00000000-0000-0000-0000-000000000001'::UUID;
        $test$,
        '0',
        'security'
    );

    -- Test financial calculations
    PERFORM testing.run_test(
        'invoice_balance_calculation',
        $test$
        SELECT CASE WHEN balance_due = total_amount - paid_amount THEN 'correct' ELSE 'incorrect' END
        FROM financial.invoices LIMIT 1;
        $test$,
        'correct',
        'financial'
    );

    RETURN QUERY
    SELECT tr.test_name, tr.status, tr.message 
    FROM testing.test_results tr 
    WHERE tr.run_at >= NOW() - INTERVAL '1 minute'
    ORDER BY tr.run_at DESC;
END;
$$ LANGUAGE plpgsql;

Entity Framework IntegrationΒΆ

// Example Entity Framework configuration for multi-schema setup
public class PropertyManagementDbContext : DbContext
{
    public PropertyManagementDbContext(DbContextOptions<PropertyManagementDbContext> options) 
        : base(options) { }

    // Identity Schema
    public DbSet<UserProfile> UserProfiles { get; set; }
    public DbSet<AuthSession> AuthSessions { get; set; }
    public DbSet<UserRole> UserRoles { get; set; }

    // Core Schema
    public DbSet<Organization> Organizations { get; set; }
    public DbSet<Property> Properties { get; set; }
    public DbSet<Unit> Units { get; set; }
    public DbSet<Tenant> Tenants { get; set; }
    public DbSet<Lease> Leases { get; set; }

    // Financial Schema
    public DbSet<Invoice> Invoices { get; set; }
    public DbSet<Payment> Payments { get; set; }
    public DbSet<Expense> Expenses { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Set default schema
        modelBuilder.HasDefaultSchema("core");

        // Configure schema mappings
        modelBuilder.Entity<UserProfile>().ToTable("user_profiles", "identity");
        modelBuilder.Entity<AuthSession>().ToTable("auth_sessions", "identity");
        modelBuilder.Entity<UserRole>().ToTable("user_roles", "identity");

        modelBuilder.Entity<Organization>().ToTable("organizations", "core");
        modelBuilder.Entity<Property>().ToTable("properties", "core");
        modelBuilder.Entity<Unit>().ToTable("units", "core");
        modelBuilder.Entity<Tenant>().ToTable("tenants", "core");
        modelBuilder.Entity<Lease>().ToTable("leases", "core");

        modelBuilder.Entity<Invoice>().ToTable("invoices", "financial");
        modelBuilder.Entity<Payment>().ToTable("payments", "financial");
        modelBuilder.Entity<Expense>().ToTable("expenses", "financial");

        // Configure relationships
        ConfigureIdentityEntities(modelBuilder);
        ConfigureCoreEntities(modelBuilder);
        ConfigureFinancialEntities(modelBuilder);

        // Configure RLS context
        modelBuilder.HasPostgresExtension("uuid-ossp");

        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Set organization context before saving changes
        var organizationId = GetCurrentOrganizationId();
        if (organizationId != null)
        {
            await Database.ExecuteSqlRawAsync(
                "SELECT set_config('app.current_organization_id', {0}, true)",
                organizationId.ToString()
            );
        }

        return await base.SaveChangesAsync(cancellationToken);
    }

    private void ConfigureCoreEntities(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Property>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Id).ValueGeneratedOnAdd();

            // Configure JSON columns
            entity.Property(e => e.Address)
                .HasColumnType("jsonb")
                .HasConversion(
                    v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
                    v => JsonSerializer.Deserialize<Address>(v, (JsonSerializerOptions)null)
                );

            // Configure arrays
            entity.Property(e => e.Amenities)
                .HasColumnName("amenities")
                .HasColumnType("text[]");

            // Configure relationships
            entity.HasMany(p => p.Units)
                .WithOne(u => u.Property)
                .HasForeignKey(u => u.PropertyId)
                .OnDelete(DeleteBehavior.Cascade);

            // Configure indexes
            entity.HasIndex(p => p.OrganizationId);
            entity.HasIndex(p => new { p.PropertyType, p.Status });
        });

        modelBuilder.Entity<Unit>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Id).ValueGeneratedOnAdd();

            // Configure constraints
            entity.HasCheckConstraint("check_unit_rent_positive", "market_rent >= 0 AND current_rent >= 0");
            entity.HasCheckConstraint("check_unit_rooms_valid", "bedrooms >= 0 AND bathrooms >= 0");

            // Configure unique constraint
            entity.HasIndex(u => new { u.PropertyId, u.UnitNumber }).IsUnique();
        });
    }

    private Guid? GetCurrentOrganizationId()
    {
        // Get organization ID from current user context, HTTP headers, etc.
        // This would typically come from your authentication/authorization system
        return null; // Implement based on your auth system
    }
}

This comprehensive database design specification provides developers and database administrators with everything needed to implement, optimize, and maintain the Property Management System's database layer. The schema-based multi-tenancy approach ensures data isolation while maintaining performance and simplicity, with clear migration paths for future scaling requirements.

Key Implementation Files: - /Users/nks/workspace/personal/property_managment/database.md - This complete specification - Schema creation scripts in migrations/ directory - Backup and monitoring scripts in scripts/ directory - Entity Framework configurations in service projects - Performance optimization queries and maintenance procedures