ποΈ 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:
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ΒΆ
- Continuous WAL Archival: Stream WAL files to S3/Azure Blob
- Daily Full Backups: pg_dump for each tenant schema
- Point-in-Time Recovery: Ability to restore to any point in last 30 days
- 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ΒΆ
- Local Development: Docker Compose with PostgreSQL + Redis
- Testing: Dedicated test database with sample data
- Staging: Production-like environment with anonymized data
- 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
- Review API Reference for database integration patterns
- Explore Development Standards for coding guidelines
- Understand Deployment for database setup procedures
ποΈ 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
- Multi-Tenant Strategy
- Schema Design & Data Models
- Row-Level Security Implementation
- Performance Optimization
- Data Integrity & Constraints
- Migration & Deployment
- Backup & Recovery
- Monitoring & Maintenance
- Development Guidelines
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